Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ as if the feature always existed.

`ws-dev server` keeps state under `<git-common-dir>/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).

- `<git-common-dir>/ws-dev/server.pid` — our own PID
- `<git-common-dir>/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
- `<git-common-dir>/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.
Expand Down
43 changes: 39 additions & 4 deletions internal/cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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
}

Expand Down
30 changes: 30 additions & 0 deletions internal/cmd/status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}
Loading