Skip to content

Commit 629ea06

Browse files
committed
non-blocking saves for non-critical writes
1 parent 37e32a5 commit 629ea06

10 files changed

Lines changed: 71 additions & 12 deletions

File tree

cmd/merge.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ func runMerge(cfg *config.Config, target string) error {
4444
syncStackPRs(cfg, s)
4545

4646
// Persist the refreshed PR state.
47-
_ = stack.Save(result.GitDir, result.StackFile)
47+
stack.SaveNonBlocking(result.GitDir, result.StackFile)
4848

4949
// Resolve which branch to operate on.
5050
var br *stack.BranchRef

cmd/rebase.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error {
305305

306306
syncStackPRs(cfg, s)
307307

308-
_ = stack.Save(gitDir, sf)
308+
stack.SaveNonBlocking(gitDir, sf)
309309

310310
merged := s.MergedBranches()
311311
if len(merged) > 0 {
@@ -496,7 +496,7 @@ func continueRebase(cfg *config.Config, gitDir string) error {
496496

497497
syncStackPRs(cfg, s)
498498

499-
_ = stack.Save(gitDir, sf)
499+
stack.SaveNonBlocking(gitDir, sf)
500500

501501
cfg.Printf("All branches in stack rebased locally with %s", s.Trunk.Branch)
502502
cfg.Printf("To push up your changes and open/update the stack of PRs, run `%s`",

cmd/sync.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ func runSync(cfg *config.Config, opts *syncOptions) error {
221221
} else {
222222
// Persist refreshed PR state even on conflict, then bail out
223223
// before pushing or reporting success.
224-
_ = stack.Save(gitDir, sf)
224+
stack.SaveNonBlocking(gitDir, sf)
225225
return ErrConflict
226226
}
227227
}

cmd/utils.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ func loadStack(cfg *config.Config, branch string) (*loadStackResult, error) {
138138
}
139139

140140
// handleSaveError translates a stack.Save error into the appropriate user
141-
// message and exit error. Lock failures return ErrLockFailed (exit 8);
141+
// message and exit error. Lock contention returns ErrLockFailed (exit 8);
142142
// other write failures return ErrSilent (exit 1).
143143
func handleSaveError(cfg *config.Config, err error) error {
144144
var lockErr *stack.LockError

cmd/view.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ func runView(cfg *config.Config, opts *viewOptions) error {
5252

5353
// Sync PR state and save (best-effort).
5454
syncStackPRs(cfg, s)
55-
_ = stack.Save(gitDir, sf)
55+
stack.SaveNonBlocking(gitDir, sf)
5656

5757
if opts.asJSON {
5858
return viewJSON(cfg, s, currentBranch)

internal/stack/lock.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,15 @@ func Lock(gitDir string) (*FileLock, error) {
5252
if err == nil {
5353
return &FileLock{f: f}, nil
5454
}
55+
if !isLockBusy(err) {
56+
// Unexpected error (e.g. bad fd) — don't retry.
57+
f.Close()
58+
return nil, fmt.Errorf("locking stack file: %w", err)
59+
}
5560
if time.Now().After(deadline) {
5661
f.Close()
57-
return nil, fmt.Errorf("timed out waiting for stack lock after %s — another gh-stack process may be running", LockTimeout)
62+
return nil, &LockError{Err: fmt.Errorf(
63+
"timed out waiting for stack lock after %s — another gh-stack process may be running", LockTimeout)}
5864
}
5965
time.Sleep(lockRetryInterval)
6066
}

internal/stack/lock_test.go

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package stack
22

33
import (
4+
"fmt"
45
"sync"
56
"testing"
67
"time"
@@ -32,9 +33,13 @@ func TestLock_BlocksUntilReleased(t *testing.T) {
3233
require.NoError(t, err)
3334

3435
acquired := make(chan struct{})
36+
errCh := make(chan error, 1)
3537
go func() {
3638
lock2, err := Lock(dir)
37-
require.NoError(t, err)
39+
if err != nil {
40+
errCh <- err
41+
return
42+
}
3843
close(acquired)
3944
lock2.Unlock()
4045
}()
@@ -43,6 +48,8 @@ func TestLock_BlocksUntilReleased(t *testing.T) {
4348
select {
4449
case <-acquired:
4550
t.Fatal("lock2 acquired while lock1 was still held")
51+
case err := <-errCh:
52+
t.Fatalf("lock2 failed: %v", err)
4653
case <-time.After(300 * time.Millisecond):
4754
// expected — lock2 is waiting
4855
}
@@ -53,6 +60,8 @@ func TestLock_BlocksUntilReleased(t *testing.T) {
5360
select {
5461
case <-acquired:
5562
// success
63+
case err := <-errCh:
64+
t.Fatalf("lock2 failed after lock1 released: %v", err)
5665
case <-time.After(5 * time.Second):
5766
t.Fatal("lock2 did not acquire after lock1 was released")
5867
}
@@ -67,24 +76,38 @@ func TestLock_SerializesConcurrentAccess(t *testing.T) {
6776

6877
// Run 10 concurrent goroutines, each adding a stack under lock.
6978
// Uses Lock + Load + SaveLocked for atomic read-modify-write.
79+
errCh := make(chan error, 10)
7080
var wg sync.WaitGroup
7181
for i := 0; i < 10; i++ {
7282
wg.Add(1)
7383
go func(idx int) {
7484
defer wg.Done()
7585

7686
lock, err := Lock(dir)
77-
require.NoError(t, err)
87+
if err != nil {
88+
errCh <- fmt.Errorf("goroutine %d Lock: %w", idx, err)
89+
return
90+
}
7891
defer lock.Unlock()
7992

8093
loaded, err := Load(dir)
81-
require.NoError(t, err)
94+
if err != nil {
95+
errCh <- fmt.Errorf("goroutine %d Load: %w", idx, err)
96+
return
97+
}
8298

8399
loaded.AddStack(makeStack("main", "branch"))
84-
require.NoError(t, SaveLocked(dir, loaded))
100+
if err := SaveLocked(dir, loaded); err != nil {
101+
errCh <- fmt.Errorf("goroutine %d SaveLocked: %w", idx, err)
102+
}
85103
}(i)
86104
}
87105
wg.Wait()
106+
close(errCh)
107+
108+
for err := range errCh {
109+
t.Error(err)
110+
}
88111

89112
// All 10 stacks should be present — no lost updates.
90113
final, err := Load(dir)

internal/stack/lock_unix.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ func tryLockFile(f *os.File) error {
1313
return syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
1414
}
1515

16+
// isLockBusy reports whether err indicates the lock is held by another process.
17+
func isLockBusy(err error) bool {
18+
return err == syscall.EWOULDBLOCK
19+
}
20+
1621
func unlockFile(f *os.File) {
1722
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
1823
}

internal/stack/lock_windows.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
package stack
44

55
import (
6+
"errors"
67
"os"
78

89
"golang.org/x/sys/windows"
@@ -22,6 +23,11 @@ func tryLockFile(f *os.File) error {
2223
)
2324
}
2425

26+
// isLockBusy reports whether err indicates the lock is held by another process.
27+
func isLockBusy(err error) bool {
28+
return errors.Is(err, windows.ERROR_LOCK_VIOLATION)
29+
}
30+
2531
func unlockFile(f *os.File) {
2632
ol := new(windows.Overlapped)
2733
_ = windows.UnlockFileEx(

internal/stack/stack.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,15 +260,34 @@ func Load(gitDir string) (*StackFile, error) {
260260

261261
// Save acquires an exclusive lock on the stack file, writes sf as JSON, and
262262
// releases the lock. The lock is held only for the duration of the write.
263+
// Returns *LockError if the lock times out due to contention.
263264
func Save(gitDir string, sf *StackFile) error {
264265
lock, err := Lock(gitDir)
265266
if err != nil {
266-
return &LockError{Err: err}
267+
return err // *LockError for contention, plain error for I/O failures
267268
}
268269
defer lock.Unlock()
269270
return writeStackFile(gitDir, sf)
270271
}
271272

273+
// SaveNonBlocking attempts to save without blocking. If another process holds
274+
// the lock, the save is silently skipped. Use this for best-effort metadata
275+
// persistence (e.g. syncing PR state in view).
276+
func SaveNonBlocking(gitDir string, sf *StackFile) {
277+
path := filepath.Join(gitDir, lockFileName)
278+
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0644)
279+
if err != nil {
280+
return
281+
}
282+
if tryLockFile(f) != nil {
283+
f.Close()
284+
return
285+
}
286+
lock := &FileLock{f: f}
287+
defer lock.Unlock()
288+
_ = writeStackFile(gitDir, sf)
289+
}
290+
272291
// SaveLocked writes the stack file without acquiring the lock. The caller
273292
// must already hold the lock (via Lock) to protect the write. Use this when
274293
// you need an atomic Load-Modify-Save sequence.

0 commit comments

Comments
 (0)