diff options
Diffstat (limited to 'internal/askcli/runlock_test.go')
| -rw-r--r-- | internal/askcli/runlock_test.go | 89 |
1 files changed, 89 insertions, 0 deletions
diff --git a/internal/askcli/runlock_test.go b/internal/askcli/runlock_test.go index f56f214..8ef8f2c 100644 --- a/internal/askcli/runlock_test.go +++ b/internal/askcli/runlock_test.go @@ -8,8 +8,15 @@ import ( "sync/atomic" "testing" "time" + + "codeberg.org/snonux/hexai/internal/filelock" ) +type lockResult struct { + unlock func() error + err error +} + func TestAcquireAskRepoLock_SerializesConcurrentHolders(t *testing.T) { tmp := t.TempDir() if err := os.MkdirAll(filepath.Join(tmp, ".git"), 0o755); err != nil { @@ -44,3 +51,85 @@ func TestAcquireAskRepoLock_SerializesConcurrentHolders(t *testing.T) { t.Fatalf("max concurrent lock holders = %d, want 1", got) } } + +func TestAcquireAskRepoLock_StaleMetadataDoesNotRotateContendedLockFile(t *testing.T) { + tmp := t.TempDir() + holder, lockPath, origInfo := prepareContendedStaleLock(t, tmp) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + resultCh := acquireLockAsync(ctx, tmp) + + select { + case result := <-resultCh: + if result.unlock != nil { + _ = result.unlock() + } + t.Fatalf("lock acquired while holder still held lock: %v", result.err) + case <-time.After(40 * time.Millisecond): + } + + curInfo, err := os.Stat(lockPath) + if err != nil { + t.Fatalf("stat contended lock: %v", err) + } + if !os.SameFile(origInfo, curInfo) { + t.Fatal("contended lock file was replaced while locked") + } + + releaseContendedLock(t, holder) + + result := <-resultCh + if result.err != nil { + t.Fatalf("contender lock: %v", result.err) + } + if result.unlock == nil { + t.Fatal("contender returned nil unlock") + } + if err := result.unlock(); err != nil { + t.Fatalf("contender unlock: %v", err) + } +} + +func prepareContendedStaleLock(t *testing.T, gitRoot string) (*os.File, string, os.FileInfo) { + t.Helper() + lockDir := filepath.Join(gitRoot, ".git") + if err := os.MkdirAll(lockDir, 0o755); err != nil { + t.Fatal(err) + } + lockPath := filepath.Join(lockDir, askRepoLockFile) + holder, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + t.Fatal(err) + } + if err := filelock.TryExclusive(holder); err != nil { + t.Fatalf("holder lock: %v", err) + } + if err := writeLockMetadata(holder, 999999, "ask"); err != nil { + t.Fatalf("write stale metadata: %v", err) + } + origInfo, err := os.Stat(lockPath) + if err != nil { + t.Fatalf("stat original lock: %v", err) + } + return holder, lockPath, origInfo +} + +func acquireLockAsync(ctx context.Context, gitRoot string) <-chan lockResult { + resultCh := make(chan lockResult, 1) + go func() { + unlock, err := acquireAskRepoLock(ctx, gitRoot) + resultCh <- lockResult{unlock: unlock, err: err} + }() + return resultCh +} + +func releaseContendedLock(t *testing.T, holder *os.File) { + t.Helper() + if err := filelock.UnlockExclusive(holder); err != nil { + t.Fatalf("release holder lock: %v", err) + } + if err := holder.Close(); err != nil { + t.Fatalf("close holder file: %v", err) + } +} |
