fix(browse): stop on a dead daemon is success — never boots a daemon to stop it (#2254)

Two changes, one contract:

- Pre-server short-circuit: `browse stop` is handled BEFORE ensureServer().
  No daemon state → "nothing to stop", exit 0. Stale state (dead pid AND
  dead port) → clean the state file, exit 0. The old flow routed stop
  through ensureServer(), which started a fresh daemon + Chromium
  (multi-second boot, resource churn) purely so it could be told to shut
  down — or crashed on the stale state.
- Reconnect branch: a connection error while sending `stop` where the pid
  turns out dead (daemon died mid-flight, between the short-circuit check
  and the send) is treated as SUCCESS — the desired end state (no daemon)
  already holds — instead of the crash-restart path.

Integration tests (stop-dead-daemon.test.ts, real spawned CLI + scratch
BROWSE_STATE_FILE): stop with no state exits 0 and spawns nothing (a
spawned daemon would have written the state file); stop with a stale state
file (dead pid + verified-closed port) exits 0, cleans the state, and
spawns nothing.

Tests: stop-dead-daemon 2 pass; busy-daemon-iron-rule 8 pass;
busy-daemon-recovery 1 pass (11/11 combined).

Fixes #2254.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-16 10:59:06 -07:00
co-authored by Claude Fable 5
parent e2f70f1704
commit 5a6dd6bf88
2 changed files with 127 additions and 0 deletions
+28
View File
@@ -742,6 +742,14 @@ async function sendCommand(state: ServerState, command: string, args: string[],
if (action === 'report-busy') {
reportDaemonBusyAndExit(oldState!.pid);
}
// #2254: `stop` against a daemon that died mid-flight is SUCCESS — the
// desired end state (no daemon) already holds. Restarting a daemon just
// to stop it again was the crash-restart loop the issue reports.
if (action === 'restart-dead' && command === 'stop') {
safeUnlinkQuiet(config.stateFile);
console.log('Daemon already stopped (cleaned stale state).');
process.exit(0);
}
// 'restart-dead' or explicit 'force-restart' → restart.
if (retries >= 1) throw new Error('[browse] Server crashed twice in a row — aborting');
if (action === 'force-restart') {
@@ -1498,6 +1506,26 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
process.exit(0);
}
// ─── Stop (pre-server short-circuit, #2254) ──────────────────
// stop must be handled BEFORE ensureServer(): stopping a daemon that is
// not running must not START one just to stop it. The old flow booted a
// fresh daemon + Chromium (multi-second, resource churn) and then told it
// to shut down — or crashed trying. No state, or dead pid + dead port →
// report "nothing to stop" and exit 0.
if (command === 'stop') {
const stopState = readState();
if (!stopState) {
console.log('No daemon running — nothing to stop.');
process.exit(0);
}
if (!isProcessAlive(stopState.pid) && !(await isServerHealthy(stopState.port))) {
safeUnlinkQuiet(config.stateFile);
console.log('No daemon running (cleaned stale state) — nothing to stop.');
process.exit(0);
}
// Live daemon → fall through to the normal sendCommand('stop') path.
}
// Special case: chain reads from stdin
if (command === 'chain' && commandArgs.length === 0) {
const stdin = await Bun.stdin.text();