ajhahn.de
← eeco
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)
		}
	}
}