From f2dd8d8a515c1a2a220836231ad1a671a5e9b73d Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Mon, 13 Apr 2026 08:09:33 +0300 Subject: 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 --- internal/stats/stats.go | 31 +++---------------------------- 1 file changed, 3 insertions(+), 28 deletions(-) (limited to 'internal/stats/stats.go') 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. -- cgit v1.2.3