summaryrefslogtreecommitdiff
path: root/internal/askcli/runlock.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/askcli/runlock.go')
-rw-r--r--internal/askcli/runlock.go147
1 files changed, 147 insertions, 0 deletions
diff --git a/internal/askcli/runlock.go b/internal/askcli/runlock.go
new file mode 100644
index 0000000..d0d7be3
--- /dev/null
+++ b/internal/askcli/runlock.go
@@ -0,0 +1,147 @@
+package askcli
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "codeberg.org/snonux/hexai/internal/filelock"
+)
+
+const askRepoLockFile = "hexai-ask.lock"
+
+var errAskLockReopen = errors.New("ask lock: reopen after stale file removal")
+
+func lockProcessLabel() string {
+ if exe, err := os.Executable(); err == nil {
+ if b := filepath.Base(exe); b != "" && b != "." {
+ return b
+ }
+ }
+ if b := filepath.Base(os.Args[0]); b != "" {
+ return b
+ }
+ return "ask"
+}
+
+func readLockHolderPID(f *os.File) int {
+ if _, err := f.Seek(0, io.SeekStart); err != nil {
+ return 0
+ }
+ var buf [64]byte
+ n, err := f.Read(buf[:])
+ if err != nil && !errors.Is(err, io.EOF) {
+ return 0
+ }
+ line := strings.TrimSpace(string(buf[:n]))
+ if line == "" {
+ return 0
+ }
+ end := strings.IndexAny(line, "\n\r \t")
+ if end >= 0 {
+ line = line[:end]
+ }
+ pid, err := strconv.Atoi(line)
+ if err != nil || pid <= 0 {
+ return 0
+ }
+ return pid
+}
+
+func writeLockMetadata(f *os.File, pid int, comm string) error {
+ if _, err := f.Seek(0, io.SeekStart); err != nil {
+ return err
+ }
+ if err := f.Truncate(0); err != nil {
+ return err
+ }
+ _, err := fmt.Fprintf(f, "%d\n%s\n", pid, comm)
+ if err != nil {
+ return err
+ }
+ return f.Sync()
+}
+
+// waitOrAcquireAskLockFD tries to take an exclusive lock on f, or blocks until ctx ends.
+// On success it writes lock metadata and returns an unlock function (which closes f).
+// errAskLockReopen means the caller should open the lock path again after stale removal.
+func waitOrAcquireAskLockFD(
+ ctx context.Context,
+ f *os.File,
+ lockPath string,
+ comm string,
+ retryTimer *time.Timer,
+) (func() error, error) {
+ for {
+ err := filelock.TryExclusive(f)
+ if err == nil {
+ if werr := writeLockMetadata(f, os.Getpid(), comm); werr != nil {
+ _ = filelock.UnlockExclusive(f)
+ _ = f.Close()
+ return nil, fmt.Errorf("ask lock: write metadata: %w", werr)
+ }
+ return func() error {
+ uErr := filelock.UnlockExclusive(f)
+ cErr := f.Close()
+ return errors.Join(uErr, cErr)
+ }, nil
+ }
+ if !errors.Is(err, filelock.ErrWouldBlock) {
+ _ = f.Close()
+ return nil, fmt.Errorf("ask lock: %w", err)
+ }
+
+ pid := readLockHolderPID(f)
+ if pid > 0 && lockHolderIsStale(pid, comm) {
+ _ = f.Close()
+ if rerr := os.Remove(lockPath); rerr != nil && !errors.Is(rerr, os.ErrNotExist) {
+ return nil, fmt.Errorf("ask lock: remove stale %s: %w", lockPath, rerr)
+ }
+ return nil, errAskLockReopen
+ }
+
+ retryTimer.Reset(5 * time.Millisecond)
+ select {
+ case <-ctx.Done():
+ _ = f.Close()
+ return nil, ctx.Err()
+ case <-retryTimer.C:
+ }
+ }
+}
+
+// acquireAskRepoLock serializes ask CLI access for a git working copy. It uses an
+// advisory lock under .git and records holder PID plus process name for stale detection.
+func acquireAskRepoLock(ctx context.Context, gitRoot string) (func() error, error) {
+ lockPath := filepath.Join(gitRoot, ".git", askRepoLockFile)
+ if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil {
+ return nil, fmt.Errorf("ask lock: mkdir: %w", err)
+ }
+
+ comm := lockProcessLabel()
+ retryTimer := time.NewTimer(5 * time.Millisecond)
+ defer retryTimer.Stop()
+
+ for removalAttempts := 0; removalAttempts < 16; removalAttempts++ {
+ f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600)
+ if err != nil {
+ return nil, fmt.Errorf("ask lock: open %s: %w", lockPath, err)
+ }
+ unlock, err := waitOrAcquireAskLockFD(ctx, f, lockPath, comm, retryTimer)
+ if err == nil {
+ return unlock, nil
+ }
+ if errors.Is(err, errAskLockReopen) {
+ continue
+ }
+ return nil, err
+ }
+
+ return nil, fmt.Errorf("ask lock: could not acquire %s after stale recovery attempts", lockPath)
+}