package queue import ( "errors" "fmt" "os" "path/filepath" "strings" "sync" "testing" "time" ) func validItem(title string) Item { return Item{ Kind: "lock-test", Title: title, Project: "p", Date: time.Date(2026, 5, 19, 0, 0, 0, 0, time.UTC), } } func TestAcquireLock_HeldLockReturnsErrLocked(t *testing.T) { dir := t.TempDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } // Write a lock owned by a live pid (this process) with a fresh mtime. lockPath := filepath.Join(dir, LockName) contents := []byte("pid=" + fmt.Sprint(os.Getpid()) + "\ntime=2099-01-01T00:00:00Z\n") if err := os.WriteFile(lockPath, contents, 0o644); err != nil { t.Fatal(err) } err := Append(dir, validItem("blocked")) if !errors.Is(err, ErrLocked) { t.Fatalf("expected ErrLocked, got %v", err) } if _, err := os.Stat(filepath.Join(dir, Filename)); !errors.Is(err, os.ErrNotExist) { t.Errorf("queue.md should not exist on contention; stat err=%v", err) } if _, err := os.Stat(lockPath); err != nil { t.Errorf("contender removed an active lock: %v", err) } } func TestAcquireLock_StaleLockByMtimeIsTakenOver(t *testing.T) { dir := t.TempDir() lockPath := filepath.Join(dir, LockName) // Use a pid that very likely exists (init / launchd is pid 1 on // Unix, always alive). The mtime is the takeover signal. if err := os.WriteFile(lockPath, []byte("pid=1\ntime=2099-01-01T00:00:00Z\n"), 0o644); err != nil { t.Fatal(err) } old := time.Now().Add(-10 * time.Minute) if err := os.Chtimes(lockPath, old, old); err != nil { t.Fatal(err) } if err := Append(dir, validItem("taken-over")); err != nil { t.Fatalf("Append after stale lock: %v", err) } if _, err := os.Stat(filepath.Join(dir, Filename)); err != nil { t.Errorf("queue.md missing after takeover: %v", err) } if _, err := os.Stat(lockPath); !errors.Is(err, os.ErrNotExist) { t.Errorf("lock not released after takeover (stat err=%v)", err) } } func TestAcquireLock_DeadPIDOnUnixIsTakenOver(t *testing.T) { // On Windows processAlive is always true, so this branch only // exercises the Unix kill(pid, 0) path. The mtime is fresh so only // the dead-PID path can succeed. if !canProbePIDs() { t.Skip("PID liveness probe unavailable on this platform") } dir := t.TempDir() lockPath := filepath.Join(dir, LockName) deadPID := pickDeadPID(t) contents := fmt.Sprintf("pid=%d\ntime=%s\n", deadPID, time.Now().UTC().Format(time.RFC3339)) if err := os.WriteFile(lockPath, []byte(contents), 0o644); err != nil { t.Fatal(err) } if err := Append(dir, validItem("dead-pid-takeover")); err != nil { t.Fatalf("Append after dead-pid lock: %v", err) } if _, err := os.Stat(filepath.Join(dir, Filename)); err != nil { t.Errorf("queue.md missing after dead-pid takeover: %v", err) } } func TestAppend_ReleaseLockAfterSuccess(t *testing.T) { dir := t.TempDir() if err := Append(dir, validItem("first")); err != nil { t.Fatal(err) } if _, err := os.Stat(filepath.Join(dir, LockName)); !errors.Is(err, os.ErrNotExist) { t.Errorf("lock not released after successful Append: %v", err) } // Second Append must succeed in the same dir. if err := Append(dir, validItem("second")); err != nil { t.Fatalf("second Append: %v", err) } } func TestAppend_RealConcurrencyNoCorruption(t *testing.T) { dir := t.TempDir() const goroutines = 30 var wg sync.WaitGroup var mu sync.Mutex var nilCount, lockedCount, otherErrs int wg.Add(goroutines) for i := range goroutines { go func(i int) { defer wg.Done() err := Append(dir, validItem(fmt.Sprintf("concurrent-%02d", i))) mu.Lock() defer mu.Unlock() switch { case err == nil: nilCount++ case errors.Is(err, ErrLocked): lockedCount++ default: otherErrs++ t.Errorf("unexpected error: %v", err) } }(i) } wg.Wait() if otherErrs > 0 { t.Fatalf("unexpected errors: %d", otherErrs) } if nilCount+lockedCount != goroutines { t.Fatalf("accounting: nil=%d locked=%d total=%d", nilCount, lockedCount, goroutines) } b, err := os.ReadFile(filepath.Join(dir, Filename)) if err != nil { t.Fatal(err) } got := string(b) // Count "- [ ]" lines matches nil-returning writers. checkedLines := 0 for line := range strings.SplitSeq(got, "\n") { if strings.HasPrefix(strings.TrimSpace(line), "- [ ]") { checkedLines++ } } if checkedLines != nilCount { t.Errorf("queue items %d does not match successful writers %d:\n%s", checkedLines, nilCount, got) } // Exactly one header. if strings.Count(got, "# eeco queue") != 1 { t.Errorf("header count != 1:\n%s", got) } // No malformed line: every item line is a "- [ ] **kind** — title _(p, date)_" pattern. for line := range strings.SplitSeq(got, "\n") { trimmed := strings.TrimSpace(line) if !strings.HasPrefix(trimmed, "- [ ]") { continue } if !strings.Contains(trimmed, "**lock-test**") || !strings.Contains(trimmed, "_(p, 2026-05-19)_") { t.Errorf("malformed item line (possible torn write): %q", trimmed) } } // Lock is removed (every writer released its successful claim). // Windows can leave the dir entry briefly in "pending delete" after // the last handle closes; poll for the entry to disappear before // asserting. On Unix the first iteration breaks immediately. deadline := time.Now().Add(2 * time.Second) for { _, err := os.Stat(filepath.Join(dir, LockName)) if errors.Is(err, os.ErrNotExist) { break } if time.Now().After(deadline) { t.Errorf("lock not removed after concurrent run: stat err=%v", err) break } time.Sleep(20 * time.Millisecond) } } // canProbePIDs reports whether processAlive can distinguish alive from // dead PIDs. On Windows it cannot (always returns true), so dead-PID // tests are skipped there; the mtime path is the takeover signal. func canProbePIDs() bool { // pid 1 always exists on Unix; on Windows processAlive is hard-coded // to true so dead detection is impossible. We probe a clearly dead // pid (max int32) and expect false on Unix, true on Windows. const obviouslyDead = 0x7fffff00 return !processAlive(obviouslyDead) } func pickDeadPID(t *testing.T) int { t.Helper() candidates := []int{0x7fffff00, 0x7ffffff0, 0x7fffffff - 1} for _, c := range candidates { if !processAlive(c) { return c } } t.Fatal("could not find a dead pid") return 0 } // lockStateDirIsFile returns a stateDir that is itself a regular FILE, so // the lock path's parent is a non-directory and tryClaim's // O_CREATE|O_EXCL open fails with ENOTDIR (a real I/O error, not // fs.ErrExist). A directory placed where the lock file goes would instead // return fs.ErrExist and route through the contention path, so the // file-parent trick is what reaches the error-wrap. No chmod, so root CI // cannot bypass it. func lockStateDirIsFile(t *testing.T) string { t.Helper() stateDir := filepath.Join(t.TempDir(), "statefile") if err := os.WriteFile(stateDir, []byte("x"), 0o644); err != nil { t.Fatal(err) } return stateDir } // lockParentIsFile returns a stateDir whose parent is a regular file, so // os.MkdirAll(stateDir) fails with ENOTDIR. func lockParentIsFile(t *testing.T) string { t.Helper() parent := filepath.Join(t.TempDir(), "afile") if err := os.WriteFile(parent, []byte("x"), 0o644); err != nil { t.Fatal(err) } return filepath.Join(parent, "state") } func TestAcquireLock_OpenErrWrapped(t *testing.T) { // Direct white-box call: a file-parented lock path makes tryClaim's // O_CREATE|O_EXCL open fail with ENOTDIR → isPendingDelete(err) false // on unix → the "queue.lock:" wrap, propagated by acquireLock. stateDir := lockStateDirIsFile(t) release, err := acquireLock(stateDir) if err == nil || !strings.Contains(err.Error(), "queue.lock:") { t.Fatalf("acquireLock err = %v, want 'queue.lock:'", err) } // The returned release must be a safe no-op on the error path. release() } func TestAppend_MkdirStateDirFails(t *testing.T) { err := Append(lockParentIsFile(t), validItem("x")) if err == nil || !strings.Contains(err.Error(), "queue.Append: create state dir:") { t.Fatalf("Append err = %v, want 'queue.Append: create state dir:'", err) } } func TestAppendUnique_MkdirStateDirFails(t *testing.T) { _, err := AppendUnique(lockParentIsFile(t), validItem("x")) if err == nil || !strings.Contains(err.Error(), "queue.AppendUnique: create state dir:") { t.Fatalf("AppendUnique err = %v, want 'queue.AppendUnique: create state dir:'", err) } } func TestLockIsStale_StatErr(t *testing.T) { if lockIsStale(filepath.Join(t.TempDir(), "nope")) { t.Error("lockIsStale(missing) = true, want false") } } func TestLockIsStale_FreshNoPID(t *testing.T) { p := filepath.Join(t.TempDir(), LockName) if err := os.WriteFile(p, []byte("no pid here\n"), 0o644); err != nil { t.Fatal(err) } // Fresh mtime + unreadable pid → not safe to reclaim. if lockIsStale(p) { t.Error("lockIsStale(fresh, no pid) = true, want false") } } func TestReadLockPID_Errors(t *testing.T) { // Read error: the path is a directory. if pid, ok := readLockPID(t.TempDir()); ok || pid != 0 { t.Errorf("readLockPID(dir) = (%d,%v), want (0,false)", pid, ok) } // Malformed / non-positive pid values and a missing pid line. for _, body := range []string{"pid=-5\n", "pid=abc\n", "pid=0\n", "no-pid-line\n"} { p := filepath.Join(t.TempDir(), LockName) if err := os.WriteFile(p, []byte(body), 0o644); err != nil { t.Fatal(err) } if pid, ok := readLockPID(p); ok || pid != 0 { t.Errorf("readLockPID(%q) = (%d,%v), want (0,false)", body, pid, ok) } } }