summaryrefslogtreecommitdiff
path: root/internal/stats
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-13 08:09:33 +0300
committerPaul Buetow <paul@buetow.org>2026-04-13 08:10:16 +0300
commitf2dd8d8a515c1a2a220836231ad1a671a5e9b73d (patch)
tree5b19585afb01b60d03d24a96b57bc7b986ea4cc0 /internal/stats
parent56002ff942de1bfb0ce467ec37a692b8c4ca01e9 (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.go23
-rw-r--r--internal/stats/lock_windows.go24
-rw-r--r--internal/stats/stats.go31
-rw-r--r--internal/stats/stats_test.go8
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)
}