diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-13 08:09:33 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-13 08:10:16 +0300 |
| commit | f2dd8d8a515c1a2a220836231ad1a671a5e9b73d (patch) | |
| tree | 5b19585afb01b60d03d24a96b57bc7b986ea4cc0 /internal/stats | |
| parent | 56002ff942de1bfb0ce467ec37a692b8c4ca01e9 (diff) | |
ask: serialize concurrent CLI with repo lock and stale PID recovery
Add advisory lock under .git/hexai-ask.lock around Taskwarrior execution,
with metadata (PID and process basename) and Linux /proc comm checks to
remove orphan lock files when the recorded holder is gone or not ask.
Extract internal/filelock for shared flock helpers; stats uses it too.
Made-with: Cursor
Diffstat (limited to 'internal/stats')
| -rw-r--r-- | internal/stats/lock_posix.go | 23 | ||||
| -rw-r--r-- | internal/stats/lock_windows.go | 24 | ||||
| -rw-r--r-- | internal/stats/stats.go | 31 | ||||
| -rw-r--r-- | internal/stats/stats_test.go | 8 |
4 files changed, 8 insertions, 78 deletions
diff --git a/internal/stats/lock_posix.go b/internal/stats/lock_posix.go deleted file mode 100644 index 2c41d31..0000000 --- a/internal/stats/lock_posix.go +++ /dev/null @@ -1,23 +0,0 @@ -//go:build !windows - -package stats - -import ( - "errors" - - "golang.org/x/sys/unix" -) - -func tryLockFile(fd uintptr) error { - if err := unix.Flock(int(fd), unix.LOCK_EX|unix.LOCK_NB); err != nil { - if errors.Is(err, unix.EWOULDBLOCK) { - return errLockWouldBlock - } - return err - } - return nil -} - -func unlockFile(fd uintptr) error { - return unix.Flock(int(fd), unix.LOCK_UN) -} diff --git a/internal/stats/lock_windows.go b/internal/stats/lock_windows.go deleted file mode 100644 index 2ec5e90..0000000 --- a/internal/stats/lock_windows.go +++ /dev/null @@ -1,24 +0,0 @@ -//go:build windows - -package stats - -import ( - "golang.org/x/sys/windows" -) - -func tryLockFile(fd uintptr) error { - var ol windows.Overlapped - err := windows.LockFileEx(windows.Handle(fd), windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, &ol) - if err == nil { - return nil - } - if err == windows.ERROR_LOCK_VIOLATION { - return errLockWouldBlock - } - return err -} - -func unlockFile(fd uintptr) error { - var ol windows.Overlapped - return windows.UnlockFileEx(windows.Handle(fd), 0, 1, 0, &ol) -} diff --git a/internal/stats/stats.go b/internal/stats/stats.go index bd91e20..a5c5cf1 100644 --- a/internal/stats/stats.go +++ b/internal/stats/stats.go @@ -16,6 +16,8 @@ import ( "strings" "sync/atomic" "time" + + "codeberg.org/snonux/hexai/internal/filelock" ) const ( @@ -27,8 +29,6 @@ const ( var windowSeconds int64 = int64(defaultWindow.Seconds()) -var errLockWouldBlock = errors.New("stats: lock would block") - // nowFunc is the clock source for event timestamps and pruning cutoffs. // Replaced in tests to control time without sleeping. var nowFunc = time.Now @@ -141,7 +141,7 @@ func lockStatsFile(ctx context.Context, dir string) (func() error, error) { if err != nil { return nil, err } - unlock, err := acquireFileLock(ctx, f) + unlock, err := filelock.AcquireExclusive(ctx, f) if err != nil { _ = f.Close() return nil, err @@ -215,31 +215,6 @@ func writeStatsFileAtomic(dir, path string, sf *File) error { return nil } -// acquireFileLock spins on tryLockFile until it succeeds, the context is -// cancelled, or an unexpected error occurs. A single timer is reused across -// retries to avoid leaking timers/channels on every loop iteration. -func acquireFileLock(ctx context.Context, f *os.File) (func() error, error) { - fd := f.Fd() - retryTimer := time.NewTimer(5 * time.Millisecond) - defer retryTimer.Stop() - for { - err := tryLockFile(fd) - if err == nil { - return func() error { return unlockFile(fd) }, nil - } - if errors.Is(err, errLockWouldBlock) { - retryTimer.Reset(5 * time.Millisecond) - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-retryTimer.C: - } - continue - } - return nil, err - } -} - // TakeSnapshot reads the stats file and aggregates events within the stored // window (falling back to the process-level Window() if the file has none). // This is a pure read — it does not mutate global state. diff --git a/internal/stats/stats_test.go b/internal/stats/stats_test.go index 47e3068..fc043a5 100644 --- a/internal/stats/stats_test.go +++ b/internal/stats/stats_test.go @@ -9,6 +9,8 @@ import ( "sync" "testing" "time" + + "codeberg.org/snonux/hexai/internal/filelock" ) func TestUpdateAndSnapshot_Single(t *testing.T) { @@ -309,7 +311,7 @@ func TestTakeSnapshot_ZeroWindowSeconds(t *testing.T) { } // TestUpdate_CancelledContext covers the context cancellation branch in -// acquireFileLock when the lock is already held. +// filelock.AcquireExclusive when the lock is already held. func TestUpdate_CancelledContext(t *testing.T) { dir := t.TempDir() t.Setenv("XDG_CACHE_HOME", dir) @@ -320,14 +322,14 @@ func TestUpdate_CancelledContext(t *testing.T) { t.Fatal(err) } - // Hold the lock file to force acquireFileLock to spin. + // Hold the lock file to force filelock.AcquireExclusive to spin. lockPath := filepath.Join(statsDir, lockFileName) lf, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600) if err != nil { t.Fatal(err) } defer func() { _ = lf.Close() }() - unlock, err := acquireFileLock(context.Background(), lf) + unlock, err := filelock.AcquireExclusive(context.Background(), lf) if err != nil { t.Fatal(err) } |
