diff --git a/backend/internal/cli/root_test.go b/backend/internal/cli/root_test.go index 73387be88..307b80a57 100644 --- a/backend/internal/cli/root_test.go +++ b/backend/internal/cli/root_test.go @@ -153,6 +153,81 @@ func TestStopDoesNotSignalUnverifiedReusedPID(t *testing.T) { } } +func TestStatusKeepsLiveProbeFailureUnhealthy(t *testing.T) { + cfg := setConfigEnv(t) + if err := runfile.Write(cfg.runFile, runfile.Info{PID: 4242, Port: closedPort(t), StartedAt: time.Unix(100, 0).UTC()}); err != nil { + t.Fatal(err) + } + + out, _, err := executeCLI(t, Deps{ + ProcessAlive: func(pid int) bool { return pid == 4242 }, + }, "status", "--json") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, `"state": "unhealthy"`) { + t.Fatalf("status should keep live probe failures unhealthy:\n%s", out) + } + info, err := runfile.Read(cfg.runFile) + if err != nil { + t.Fatal(err) + } + if info == nil { + t.Fatal("live probe failure should not remove run-file") + } +} + +func TestStopRefusesUnverifiedLivePID(t *testing.T) { + cfg := setConfigEnv(t) + if err := runfile.Write(cfg.runFile, runfile.Info{PID: 4242, Port: closedPort(t), StartedAt: time.Unix(100, 0).UTC()}); err != nil { + t.Fatal(err) + } + + var signaled bool + _, _, err := executeCLI(t, Deps{ + ProcessAlive: func(pid int) bool { return pid == 4242 }, + SignalTerm: func(pid int) error { + signaled = true + return nil + }, + }, "stop", "--json") + if err == nil { + t.Fatal("stop should fail when daemon ownership cannot be verified") + } + if signaled { + t.Fatal("stop signaled a PID whose ownership was unknown") + } + info, err := runfile.Read(cfg.runFile) + if err != nil { + t.Fatal(err) + } + if info == nil { + t.Fatal("unverified live PID should remain tracked") + } +} + +func TestStartDoesNotSpawnWhenLiveProbeFails(t *testing.T) { + cfg := setConfigEnv(t) + if err := runfile.Write(cfg.runFile, runfile.Info{PID: 4242, Port: closedPort(t), StartedAt: time.Unix(100, 0).UTC()}); err != nil { + t.Fatal(err) + } + + var started bool + _, _, err := executeCLI(t, Deps{ + ProcessAlive: func(pid int) bool { return pid == 4242 }, + StartProcess: func(processStartConfig) (processHandle, error) { + started = true + return processHandle{}, nil + }, + }, "start", "--timeout", "1ns", "--json") + if err == nil { + t.Fatal("start should fail instead of spawning over a live unverified PID") + } + if started { + t.Fatal("start spawned while run-file PID was still alive") + } +} + type testConfig struct { runFile string dataDir string @@ -203,3 +278,22 @@ func serverPort(t *testing.T, raw string) int { } return port } + +func closedPort(t *testing.T) int { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + + _, portRaw, err := net.SplitHostPort(ln.Addr().String()) + if err != nil { + t.Fatal(err) + } + port, err := strconv.Atoi(portRaw) + if err != nil { + t.Fatal(err) + } + return port +} diff --git a/backend/internal/cli/status.go b/backend/internal/cli/status.go index ea6cb5280..85cbf5ac6 100644 --- a/backend/internal/cli/status.go +++ b/backend/internal/cli/status.go @@ -89,7 +89,7 @@ func (c *commandContext) inspectDaemon(ctx context.Context) (daemonStatus, error health, err := c.readProbe(ctx, info.Port, "healthz") if err != nil { - st.State = "stale" + st.State = "unhealthy" st.Error = err.Error() return st, nil } diff --git a/backend/internal/cli/stop.go b/backend/internal/cli/stop.go index 1b6269409..f6817c0fd 100644 --- a/backend/internal/cli/stop.go +++ b/backend/internal/cli/stop.go @@ -62,10 +62,10 @@ func (c *commandContext) stopDaemon(ctx context.Context, opts stopOptions) (daem return daemonStatus{State: "stopped", RunFile: cfg.RunFilePath, DataDir: cfg.DataDir}, nil } if !st.owned { - if err := runfile.Remove(cfg.RunFilePath); err != nil { - return daemonStatus{}, err + if st.Error != "" { + return daemonStatus{}, fmt.Errorf("daemon pid %d is alive but ownership could not be verified: %s", st.PID, st.Error) } - return daemonStatus{State: "stopped", RunFile: cfg.RunFilePath, DataDir: cfg.DataDir}, nil + return daemonStatus{}, fmt.Errorf("daemon pid %d is alive but ownership could not be verified", st.PID) } if err := c.deps.SignalTerm(st.PID); err != nil {