Files
pad/internal/cli/pid_file_test.go
T
xarmian df5531b815 fix(cli): pad server stop keeps a live legacy PID file instead of deleting it, and clears a dead one (BUG-2970) (#1485)
* fix(cli): pad server stop keeps a legacy bare-pid file instead of deleting a live server's record (BUG-2970)

On Unix, pidFileOwner read a free lock as proof that a record was stale, and
removed it. That holds for records this build writes, because their server
holds the lock for its whole life. It does not hold for a bare-pid file written
before BUG-2969: no build that wrote that form took a lock, so a free lock says
nothing about its server, which may be live. The day-19 refresh deleted exactly
such a record.

A record without a fingerprint (zero StartedAt, which is what a bare pid
parses to) is now unprovable and left in place, the answer Windows already
gave for the same shape. Fingerprinted records are unchanged. Three existing
fixtures modelled fingerprinted records without a StartedAt, which no build
writes; they now carry one.

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N

* fix(cli): a legacy PID file naming a dead process is stale and removed; start is never blocked by a kept one (BUG-2970)

The kept, unprovable state now applies only while the legacy record's pid
exists. A pid with no process behind it cannot be anyone's server, so that
record is stale by the same proof as an unheld fingerprinted one, and is
removed under the lock. Stop then says "server not running" instead of
refusing forever.

Tests pin both halves of the way out. Start is never blocked: EnsureServer
probes the port, and the new server's ClaimPIDFile takes over a kept legacy
file and writes a fingerprinted record stop can address. And the kept
refusal names its manual remedy (remove <path>).

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
2026-09-23 23:26:24 -04:00

369 lines
14 KiB
Go

//go:build unix
package cli
import (
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/PerpetualSoftware/pad/internal/config"
)
// BUG-2965 and BUG-2969 together: the PID file must let `pad server stop`
// address the running server, and must not let it signal anything else.
//
// These are the Unix half — ownership here is an advisory lock, so the tests
// exercise flock behaviour directly. The Windows half (creation-time
// comparison) is covered by the CI smoke on windows-latest, which runs
// `pad server stop` against a server it started.
func TestClaimPIDFile_WritesARecordAndReleasesIt(t *testing.T) {
path := filepath.Join(t.TempDir(), "pad.pid")
release := ClaimPIDFile(path)
rec, ok := readPIDRecord(path)
if !ok {
t.Fatal("no PID record written")
}
if rec.PID != os.Getpid() {
t.Errorf("record names pid %d, want this process (%d)", rec.PID, os.Getpid())
}
if rec.StartedAt.IsZero() {
t.Error("record has no start time — the human-readable half is what a person reads to identify the process")
}
if rec.Exe == "" {
t.Error("record has no executable path")
}
release()
if _, ok := readPIDRecord(path); ok {
t.Error("PID file survived release")
}
}
// TestClaimPIDFile_HoldsTheLockWhileRunning is the ownership claim itself: while
// a server is up, the file reports as OURS, which is the only state in which
// stop is allowed to signal.
func TestClaimPIDFile_HoldsTheLockWhileRunning(t *testing.T) {
path := filepath.Join(t.TempDir(), "pad.pid")
release := ClaimPIDFile(path)
defer release()
if _, ok := readPIDRecord(path); !ok {
t.Fatal("no PID record written")
}
if _, got := pidFileOwner(path); got != pidFileOurs {
t.Errorf("pidFileOwner = %v while the claim is held, want ours", got)
}
}
// TestPIDFileOwner_UnheldFileIsStale is the defect's core, expressed as a
// property: a record nobody holds is stale NO MATTER WHAT PID IT NAMES. The pid
// here is this live test process — the strongest form of the case, because a
// liveness check would call it ours and signal it.
func TestPIDFileOwner_UnheldFileIsStale(t *testing.T) {
path := filepath.Join(t.TempDir(), "pad.pid")
rec := pidRecord{PID: os.Getpid(), StartedAt: time.Now().UTC()}
if err := writePIDRecord(path, rec); err != nil {
t.Fatalf("seed: %v", err)
}
if _, got := pidFileOwner(path); got != pidFileStale {
t.Errorf("pidFileOwner = %v for an unheld file naming a LIVE pid, want stale — "+
"liveness is not ownership, and this is the pid a stranger would hold after reuse", got)
}
}
func TestPIDFileOwner_MissingFileIsStale(t *testing.T) {
path := filepath.Join(t.TempDir(), "pad.pid")
if _, got := pidFileOwner(path); got != pidFileStale {
t.Errorf("pidFileOwner = %v for a missing file, want stale", got)
}
}
func TestReadPIDRecord_AcceptsTheLegacyBarePID(t *testing.T) {
path := filepath.Join(t.TempDir(), "pad.pid")
if err := os.WriteFile(path, []byte(strconv.Itoa(4242)+"\n"), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
rec, ok := readPIDRecord(path)
if !ok {
t.Fatal("legacy bare-pid file did not parse — a file written by an older binary is exactly the case this family of bugs is about")
}
if rec.PID != 4242 {
t.Errorf("pid = %d, want 4242", rec.PID)
}
if !rec.StartedAt.IsZero() {
t.Error("a legacy record must carry no fingerprint, so ownership reads as unprovable rather than proven")
}
}
func TestReadPIDRecord_RejectsGarbage(t *testing.T) {
for name, body := range map[string]string{
"empty": "",
"whitespace": " \n",
"not a number": "not-a-pid",
"zero": "0",
"negative": "-1",
"broken json": `{"pid": `,
"json zero pid": `{"pid": 0}`,
} {
path := filepath.Join(t.TempDir(), "pad.pid")
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("%s: seed: %v", name, err)
}
if _, ok := readPIDRecord(path); ok {
t.Errorf("%s: parsed as a valid record", name)
}
}
}
// TestClaimPIDFile_ReleaseIsIdempotent covers the shutdown path, which calls
// release explicitly before the listener closes AND keeps it deferred as a
// backstop.
func TestClaimPIDFile_ReleaseIsIdempotent(t *testing.T) {
path := filepath.Join(t.TempDir(), "pad.pid")
release := ClaimPIDFile(path)
release()
release()
if _, ok := readPIDRecord(path); ok {
t.Error("PID file present after two releases")
}
}
// TestProcessIsGone_LiveAndDead pins the confirmation predicate. The old wait
// loop asked whether the PORT was quiet, which a port nothing ever served
// answers instantly — that is how a kill that never touched a pad server
// reported "Server stopped".
func TestProcessIsGone_LiveAndDead(t *testing.T) {
if processIsGone(os.Getpid()) {
t.Error("processIsGone(self) = true")
}
if !processIsGone(-1) {
t.Error("processIsGone(-1) = false")
}
}
// TestPIDFileOwner_ReportsTheRecordItVerified is codex round 1's first race,
// as a property rather than a timing test: the record the ownership check
// hands back must be the one it read from the descriptor it probed, so a
// caller cannot signal a pid whose ownership was never established.
//
// A successor claim rewrites the file; the check must then report the
// SUCCESSOR's pid, never a pid a caller read earlier.
func TestPIDFileOwner_ReportsTheRecordItVerified(t *testing.T) {
path := filepath.Join(t.TempDir(), "pad.pid")
// Predecessor's record, unheld.
if err := writePIDRecord(path, pidRecord{PID: 111, StartedAt: time.Now().UTC()}); err != nil {
t.Fatalf("seed: %v", err)
}
// The successor claims it — same path, its own pid, lock held.
release := ClaimPIDFile(path)
defer release()
rec, owner := pidFileOwner(path)
if owner != pidFileOurs {
t.Fatalf("owner = %v, want ours", owner)
}
if rec.PID != os.Getpid() {
t.Errorf("verified record names pid %d, want the successor (%d) — signalling the earlier pid would "+
"send SIGTERM to whatever now holds it", rec.PID, os.Getpid())
}
}
// TestPIDFileOwner_HeldButUnreadableIsUnprovable covers the claim caught
// mid-write: the lock says held, the bytes do not parse, and there is no pid we
// can justify signalling.
func TestPIDFileOwner_HeldButUnreadableIsUnprovable(t *testing.T) {
path := filepath.Join(t.TempDir(), "pad.pid")
release := ClaimPIDFile(path)
defer release()
// Truncate the contents while the lock is still held.
if err := os.WriteFile(path, []byte("{"), 0o644); err != nil {
t.Fatalf("truncate: %v", err)
}
if _, owner := pidFileOwner(path); owner != pidFileUnprovable {
t.Errorf("owner = %v for a held but unparseable file, want unprovable", owner)
}
}
// TestPIDFileOwner_RemovesTheStaleFileItself pins where cleanup happens. It has
// to be inside the ownership check, while the lock is held: a caller that
// removed the file afterwards would race a replacement server's claim and
// delete ITS record (codex round 2). Asserting the file is gone when the check
// returns is how that placement stays put.
func TestPIDFileOwner_RemovesTheStaleFileItself(t *testing.T) {
path := filepath.Join(t.TempDir(), "pad.pid")
if err := writePIDRecord(path, pidRecord{PID: os.Getpid(), StartedAt: time.Now().UTC()}); err != nil {
t.Fatalf("seed: %v", err)
}
if _, owner := pidFileOwner(path); owner != pidFileStale {
t.Fatalf("owner = %v, want stale", owner)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("stale PID file survived the ownership check (stat err = %v) — cleanup must happen while the "+
"check holds the lock, not afterwards", err)
}
}
// TestPIDFileOwner_LegacyBarePIDIsUnprovableAndKept is BUG-2970. A bare-pid
// file was written by a build that predates the lock (BUG-2969), so the free
// lock on it proves nothing: its server never took one, and may be answering
// right now. That is what happened on the day-19 refresh, where the probe
// deleted the record of a live pre-fix server on its way to refusing.
//
// Two assertions, because each catches a different wrong answer: the verdict
// (stale would permit the cleanup path) and the FILE (a fix that returned
// unprovable after removing it would pass the first and still lose the
// record). The pid is this live test process, the strongest form.
func TestPIDFileOwner_LegacyBarePIDIsUnprovableAndKept(t *testing.T) {
path := filepath.Join(t.TempDir(), "pad.pid")
legacy := []byte(strconv.Itoa(os.Getpid()) + "\n")
if err := os.WriteFile(path, legacy, 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
if _, owner := pidFileOwner(path); owner != pidFileUnprovable {
t.Errorf("owner = %v for an unheld legacy bare-pid file, want unprovable — no build that wrote this "+
"form took the lock, so a free lock says nothing about its server", owner)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("the legacy PID file was removed by the ownership check (%v) — that deletes the only record "+
"of a server that may be live", err)
}
if string(got) != string(legacy) {
t.Errorf("legacy PID file rewritten: %q, want %q", got, legacy)
}
// Not left locked: a successor starting now must be able to claim it.
release := ClaimPIDFile(path)
release()
}
// TestClaimPIDFile_RetriesPastABriefProbe covers the interaction the round-2
// fix created: `stop`'s ownership probe holds the lock for an instant, and a
// server claiming in that instant must not lose its claim for the rest of its
// life. The probe here is a real held lock released while the claim is trying.
func TestClaimPIDFile_RetriesPastABriefProbe(t *testing.T) {
path := filepath.Join(t.TempDir(), "pad.pid")
probe, err := holdPIDFile(path)
if err != nil {
t.Fatalf("probe hold: %v", err)
}
done := make(chan struct{})
go func() {
time.Sleep(60 * time.Millisecond)
probe()
close(done)
}()
release := ClaimPIDFile(path)
defer release()
<-done
rec, ok := readPIDRecord(path)
if !ok || rec.PID != os.Getpid() {
t.Errorf("claim did not survive a brief foreign hold (record %+v, ok=%v)", rec, ok)
}
}
// deadPID returns the pid of a process that has already exited and been
// reaped. The number can in principle be reused before the caller looks, which
// would turn a "dead" leg into a live one; the tests using it assert the
// dead-pid behaviour only after checking processIsGone themselves.
func deadPID(t *testing.T) int {
t.Helper()
cmd := exec.Command("true")
if err := cmd.Run(); err != nil {
t.Fatalf("run a short-lived process: %v", err)
}
pid := cmd.ProcessState.Pid()
if !processIsGone(pid) {
t.Skipf("pid %d was reused before the test could look; nothing to assert", pid)
}
return pid
}
// TestPIDFileOwner_LegacyBarePIDNamingADeadProcessIsStale is the way out of
// BUG-2970's kept state (lead review, day 79): a legacy record whose pid has no
// process behind it cannot be anyone's server, so it is stale by the same
// proof as an unheld fingerprinted record, and is removed under the lock.
// Without this a dead legacy file would be kept forever and every stop would
// refuse over it.
func TestPIDFileOwner_LegacyBarePIDNamingADeadProcessIsStale(t *testing.T) {
path := filepath.Join(t.TempDir(), "pad.pid")
pid := deadPID(t)
if err := os.WriteFile(path, []byte(strconv.Itoa(pid)+"\n"), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
if _, owner := pidFileOwner(path); owner != pidFileStale {
t.Errorf("owner = %v for a legacy file naming a DEAD pid, want stale", owner)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("a legacy file naming a dead pid survived (stat err = %v); it names nothing and must not linger", err)
}
}
// TestStopServer_LegacyPIDFileNamingADeadProcessSaysNotRunning is the same
// through stop: the user sees "not running", not the unprovable refusal.
func TestStopServer_LegacyPIDFileNamingADeadProcessSaysNotRunning(t *testing.T) {
dir := t.TempDir()
cfg := &config.Config{Host: "127.0.0.1", Port: unusedPort(t), DataDir: dir}
pid := deadPID(t)
if err := os.WriteFile(cfg.PIDFile(), []byte(strconv.Itoa(pid)+"\n"), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
err := StopServer(cfg)
if err == nil || !strings.Contains(err.Error(), "server not running") {
t.Fatalf("StopServer = %v, want a \"server not running\" answer for a legacy record naming a dead pid", err)
}
if strings.Contains(err.Error(), "cannot confirm") {
t.Errorf("a dead legacy pid got the unprovable refusal %q; it is provably stale", err.Error())
}
if _, statErr := os.Stat(cfg.PIDFile()); !os.IsNotExist(statErr) {
t.Errorf("the dead legacy PID file survived stop (stat err = %v)", statErr)
}
}
// TestClaimPIDFile_OverwritesAKeptLegacyRecord is the START half of the lead's
// question: a legacy file kept as unprovable must not block a new server.
// `pad server start` never reads the file (EnsureServer probes the port), and
// the new server's claim takes it over: no lock was ever held on a legacy file,
// so the claim succeeds and writes this build's fingerprinted record.
func TestClaimPIDFile_OverwritesAKeptLegacyRecord(t *testing.T) {
path := filepath.Join(t.TempDir(), "pad.pid")
// Live pid, so the file is in the KEPT state before the claim.
if err := os.WriteFile(path, []byte(strconv.Itoa(os.Getpid())+"\n"), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
if _, owner := pidFileOwner(path); owner != pidFileUnprovable {
t.Fatalf("PRECONDITION: owner = %v, want the kept (unprovable) state", owner)
}
release := ClaimPIDFile(path)
defer release()
rec, ok := readPIDRecord(path)
if !ok || rec.PID != os.Getpid() || rec.StartedAt.IsZero() {
t.Fatalf("after the claim the file holds %+v (ok=%v), want this build's fingerprinted record", rec, ok)
}
if _, owner := pidFileOwner(path); owner != pidFileOurs {
t.Errorf("owner = %v after the claim, want ours: the new server must be addressable by stop", owner)
}
}