diff options
| author | Paul Buetow <paul@buetow.org> | 2026-06-11 08:40:10 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-06-11 08:40:10 +0300 |
| commit | 133ef49de26ae251c2c86417f9c673ebc7166f76 (patch) | |
| tree | bfcf2e105f992cc06409f193873875e2b7ea18e6 /internal/askcli/runlock_test.go | |
| parent | dc8f0ab28276fac6aa16c0cf3591c322367036b7 (diff) | |
Fix unsafe time.Timer.Reset on active timers in runlock and filelock
Both filelock.AcquireExclusive and askcli.waitOrAcquireAskLockFD created a
single time.Timer and called Reset() on it each retry iteration before it had
necessarily fired. Per the Go timer API, Reset()-ing a timer that may still be
pending is unsafe: a stale value can already be queued on the channel, causing
a spurious early wake-up.
Replace the reused-timer + Reset() pattern with a fresh time.After(...) per
loop iteration, which guarantees a clean full retry interval (or context
cancellation) every time and removes the reuse-while-active hazard. Dropped the
now-unused *time.Timer parameter from waitOrAcquireAskLockFD and introduced
named retry-interval constants.
Add a context-cancel-while-blocked test for the runlock retry loop.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat (limited to 'internal/askcli/runlock_test.go')
| -rw-r--r-- | internal/askcli/runlock_test.go | 30 |
1 files changed, 30 insertions, 0 deletions
diff --git a/internal/askcli/runlock_test.go b/internal/askcli/runlock_test.go index 8ef8f2c..c8266f0 100644 --- a/internal/askcli/runlock_test.go +++ b/internal/askcli/runlock_test.go @@ -91,6 +91,36 @@ func TestAcquireAskRepoLock_StaleMetadataDoesNotRotateContendedLockFile(t *testi } } +// TestAcquireAskRepoLock_ContextCancelledWhileBlocked verifies the retry loop +// honors context cancellation while it is parked on the per-iteration timer. +// This guards the time.After-based wait that replaced the unsafe timer reuse: +// cancellation must win the select and return ctx.Err() promptly. +func TestAcquireAskRepoLock_ContextCancelledWhileBlocked(t *testing.T) { + tmp := t.TempDir() + holder, _, _ := prepareContendedStaleLock(t, tmp) + defer releaseContendedLock(t, holder) + + ctx, cancel := context.WithCancel(context.Background()) + resultCh := acquireLockAsync(ctx, tmp) + + // Let the contender enter the retry loop, then cancel while it waits. + time.Sleep(20 * time.Millisecond) + cancel() + + select { + case result := <-resultCh: + if result.unlock != nil { + _ = result.unlock() + t.Fatal("lock acquired despite cancellation") + } + if result.err != context.Canceled { + t.Fatalf("err = %v, want context.Canceled", result.err) + } + case <-time.After(time.Second): + t.Fatal("acquireAskRepoLock did not return after cancellation") + } +} + func prepareContendedStaleLock(t *testing.T, gitRoot string) (*os.File, string, os.FileInfo) { t.Helper() lockDir := filepath.Join(gitRoot, ".git") |
