diff --git a/CLAUDE.md b/CLAUDE.md index b71e501..60c465f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,7 +105,7 @@ as if the feature always existed. `ws-dev server` keeps state under `/ws-dev/` (i.e. inside the shared `.git`, identical across all worktrees and never committed). If the previous ws-dev process is still alive it stops it with `SIGTERM` (then `SIGKILL` on timeout) before starting the new one. Running multiple worktrees in parallel is not supported (to avoid port conflicts). -- `/ws-dev/server.pid` — our own PID +- `/ws-dev/server.pid` — our own PID plus its start time (`ps -o lstart=`), compared on read so a recycled pid (e.g. after a reboot) is not mistaken for a live server - `/ws-dev/current-worktree` — most recent worktree (used by `ws-dev logs` when omitted) `ws-dev status` reads these two files (liveness via signal 0) to report whether a server is running and for which worktree; it needs no config entry, so it works in unregistered repos too. diff --git a/internal/cmd/server.go b/internal/cmd/server.go index 0d97dc6..050746e 100644 --- a/internal/cmd/server.go +++ b/internal/cmd/server.go @@ -99,7 +99,7 @@ func runServer(worktreeArg string, portBase int, logDirFlag string, background b return err } - if err := os.WriteFile(pidPath, []byte(strconv.Itoa(os.Getpid())), 0o644); err != nil { + if err := os.WriteFile(pidPath, []byte(pidFileContents(os.Getpid())), 0o644); err != nil { return err } if err := os.WriteFile(worktreePath, []byte(worktree), 0o644); err != nil { @@ -176,9 +176,33 @@ func startBackground(worktreeArg string, portBase int, logDirFlag, logAbs string return nil } +// pidFileContents renders the pid file: the pid on the first line and the +// process start time on the second, so a later reader can tell a recycled pid +// (e.g. after a reboot) apart from the recorded server. +func pidFileContents(pid int) string { + s := strconv.Itoa(pid) + if st := processStartTime(pid); st != "" { + s += "\n" + st + } + return s + "\n" +} + +// processStartTime returns the start time of pid as reported by +// `ps -o lstart=`, or "" when it cannot be determined. The value is only ever +// compared for equality, so its exact format does not matter. +func processStartTime(pid int) string { + out, err := exec.Command("ps", "-o", "lstart=", "-p", strconv.Itoa(pid)).Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + // readServerPid reads pidPath and reports the recorded pid and whether that -// process is currently alive (checked with signal 0). A missing, malformed, or -// stale pid file yields alive=false; stale files are removed on the way. +// process is currently alive (checked with signal 0) and still the process the +// file was written for (start time comparison, when recorded). A missing, +// malformed, or stale pid file yields alive=false; stale files are removed on +// the way. func readServerPid(pidPath string) (pid int, alive bool, err error) { data, err := os.ReadFile(pidPath) if err != nil { @@ -187,7 +211,8 @@ func readServerPid(pidPath string) (pid int, alive bool, err error) { } return 0, false, err } - pid, err = strconv.Atoi(strings.TrimSpace(string(data))) + lines := strings.SplitN(strings.TrimSpace(string(data)), "\n", 2) + pid, err = strconv.Atoi(strings.TrimSpace(lines[0])) if err != nil || pid <= 0 { _ = os.Remove(pidPath) return 0, false, nil @@ -202,6 +227,16 @@ func readServerPid(pidPath string) (pid int, alive bool, err error) { _ = os.Remove(pidPath) return 0, false, nil } + // Identity check: a recycled pid belongs to a process with a different + // start time than the one recorded at write time. Files without a recorded + // start time (written by older versions) skip this and rely on liveness. + if len(lines) == 2 { + recorded := strings.TrimSpace(lines[1]) + if now := processStartTime(pid); recorded != "" && now != "" && now != recorded { + _ = os.Remove(pidPath) + return 0, false, nil + } + } return pid, true, nil } diff --git a/internal/cmd/status_test.go b/internal/cmd/status_test.go index 87f989e..a363e67 100644 --- a/internal/cmd/status_test.go +++ b/internal/cmd/status_test.go @@ -34,3 +34,33 @@ func TestReadServerPid(t *testing.T) { t.Fatalf("own pid: got pid=%d alive=%v err=%v, want %d/true/nil", pid, alive, err, os.Getpid()) } } + +func TestReadServerPidStartTime(t *testing.T) { + if processStartTime(os.Getpid()) == "" { + t.Skip("ps -o lstart= not available") + } + path := filepath.Join(t.TempDir(), "server.pid") + + // A recorded start time that does not match the live process means the pid + // was recycled: the file is stale and must be removed. + content := strconv.Itoa(os.Getpid()) + "\nWed Jun 30 00:00:00 1993\n" + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + pid, alive, err := readServerPid(path) + if err != nil || alive || pid != 0 { + t.Fatalf("recycled pid: got pid=%d alive=%v err=%v, want 0/false/nil", pid, alive, err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("recycled pid file should be removed, stat err=%v", err) + } + + // A matching start time (what pidFileContents writes) stays alive. + if err := os.WriteFile(path, []byte(pidFileContents(os.Getpid())), 0o644); err != nil { + t.Fatal(err) + } + pid, alive, err = readServerPid(path) + if err != nil || !alive || pid != os.Getpid() { + t.Fatalf("matching start time: got pid=%d alive=%v err=%v, want %d/true/nil", pid, alive, err, os.Getpid()) + } +}