Go 306 lines
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)
}
}
}