package store import ( "errors" "fmt" "os" "path/filepath" "sync" "testing" ) func TestAcquireLockfile_FreshDir(t *testing.T) { dir := t.TempDir() release, err := AcquireLockfile(dir) if err != nil { t.Fatalf("AcquireLockfile: %v", err) } defer release() // Lockfile should exist with our PID. data, err := os.ReadFile(filepath.Join(dir, "tinyforge.lock")) if err != nil { t.Fatalf("read lockfile: %v", err) } want := fmt.Sprintf("%d\n", os.Getpid()) if string(data) != want { t.Errorf("lockfile content = %q, want %q", data, want) } } func TestAcquireLockfile_HeldByLivePID_Refused(t *testing.T) { dir := t.TempDir() // Plant a lockfile holding the current PID (which is obviously alive). if err := os.WriteFile(filepath.Join(dir, "tinyforge.lock"), []byte(fmt.Sprintf("%d\n", os.Getpid())), 0o600); err != nil { t.Fatalf("plant lockfile: %v", err) } release, err := AcquireLockfile(dir) if err == nil { release() t.Fatal("expected ErrLockHeld, got nil") } if !errors.Is(err, ErrLockHeld) { t.Errorf("error = %v, want wrap of ErrLockHeld", err) } } func TestAcquireLockfile_StalePID_Reclaimed(t *testing.T) { dir := t.TempDir() // PID 1 is init/launchd/systemd on POSIX and the System Idle Process // on Windows — never our process, and very unlikely to be dead. We // use a deliberately-impossible PID instead: a 31-bit value far // above any plausible system maximum. stalePID := 2147483640 if err := os.WriteFile(filepath.Join(dir, "tinyforge.lock"), []byte(fmt.Sprintf("%d\n", stalePID)), 0o600); err != nil { t.Fatalf("plant stale lockfile: %v", err) } release, err := AcquireLockfile(dir) if err != nil { t.Fatalf("expected reclaim of stale lock, got: %v", err) } defer release() // Verify it now holds OUR pid, not the stale one. data, err := os.ReadFile(filepath.Join(dir, "tinyforge.lock")) if err != nil { t.Fatalf("read lockfile after reclaim: %v", err) } want := fmt.Sprintf("%d\n", os.Getpid()) if string(data) != want { t.Errorf("lockfile content after reclaim = %q, want %q", data, want) } } func TestAcquireLockfile_ConcurrentReclaim_SingleWinner(t *testing.T) { dir := t.TempDir() // Plant a stale lockfile (impossibly high, certainly-dead PID), then have // many goroutines race to reclaim it. Exactly one must win; the rest must // be refused with ErrLockHeld. A "last-writer-wins" reclaim would let // several goroutines all believe they own the lock. stalePID := 2147483640 if err := os.WriteFile(filepath.Join(dir, "tinyforge.lock"), []byte(fmt.Sprintf("%d\n", stalePID)), 0o600); err != nil { t.Fatalf("plant stale lockfile: %v", err) } const n = 16 var ( wg sync.WaitGroup mu sync.Mutex winners int releases []func() ) start := make(chan struct{}) for i := 0; i < n; i++ { wg.Add(1) go func() { defer wg.Done() <-start release, err := AcquireLockfile(dir) if err != nil { if !errors.Is(err, ErrLockHeld) { t.Errorf("loser error = %v, want wrap of ErrLockHeld", err) } return } mu.Lock() winners++ releases = append(releases, release) mu.Unlock() }() } close(start) wg.Wait() for _, r := range releases { r() } if winners != 1 { t.Fatalf("concurrent reclaim winners = %d, want exactly 1", winners) } } func TestAcquireLockfile_ReleaseRemovesFile(t *testing.T) { dir := t.TempDir() release, err := AcquireLockfile(dir) if err != nil { t.Fatalf("AcquireLockfile: %v", err) } release() path := filepath.Join(dir, "tinyforge.lock") if _, err := os.Stat(path); !os.IsNotExist(err) { t.Errorf("lockfile still present after release: %v", err) } }