- oxfmt formatting on the new integration test file.
- Register the two exit-flush regression fixtures (support/exit-naive.ts,
support/exit-after-flush.ts) as fallow entry points: they're run as real
subprocesses via a string path (runCmdSync), which fallow's static
dependency analysis can't follow, same as the existing
test/contention-retry-fixtures/* entries. exit-payload.ts becomes
reachable transitively through their static imports. Also switched the
integration test's local PAYLOAD_MARKER duplicate to import the one
fallow flagged as unused from exit-payload.ts.
- Added real unit coverage for the new exitAfterFlush code paths, since
node --test integration files aren't measured by the vitest coverage
gate: src/utils/__tests__/process-exit.test.ts exercises the
already-drained, backlogged-then-drains, and never-drains/timeout
branches directly against a fake stream; src/__tests__/cli-exit-paths.test.ts
drives runCli() for --version, bare help, no-command, and web to cover
their exitAfterFlush call sites, plus a --debug case with a >64KB seeded
daemon.log proving printDaemonLogTailOnError's new byte cap actually
trims the oldest lines.
Changed-line coverage gate now passes at 92.59% (was 59.26%); the two
remaining uncovered lines are the bottom-of-file `isDirectRun` catch
handler, which only runs when cli.ts is executed as the literal entry
script and is not reachable by importing it as a module in a test (the
same shape as bin.ts's already-excluded top-level fast paths).
Summary
Part of #1596 (hardening; the reported replace-path failure itself was not reproduced — see review discussion, issue stays open) — the field reports of the driving process going silent (zero further tool calls) right after:
Investigation
I read through the daemon replace/takeover path (
src/daemon/client/daemon-client-lifecycle.ts) and the SESSION_NOT_FOUND emission path (src/daemon/snapshot-runtime.ts,src/daemon/handlers/*). The takeover mechanics themselves look sound:detached: true(its own process group/session) and its stdout/stderr are redirected to explicit file descriptors, never inherited from the CLI's own stdio — so a replaced daemon can't hold the CLI's own stdout pipe open;stopDaemonProcessForTakeover/isAgentDeviceDaemonProcessverify a live process by command pattern +processStartTimebefore signaling it, andisProcessAliveexplicitly rejectspid <= 0— so a PID-reuse orpid: 0→kill(0, …)group-signal footgun isn't reachable;exitedpromise (runCmdDetachedMonitored) only ever resolves, never rejects — so there's no unhandled-rejection path there;SESSION_NOT_FOUNDfrom a fresh daemon is a normal structuredok:falseresponse and already carries a default hint ("Run open first…") vianormalizeError/defaultHintForCode.I could not find or prove a hang/crash/process-group-leak mechanism in the replace/takeover code itself. What I did find and prove is a real, general Node.js correctness bug on the CLI's own exit path:
process.exit()called immediately after aprocess.stdout/process.stderrwrite can silently drop that write — Node flushes those streams synchronously only when they're a file or TTY; on a pipe (this CLI's normal condition when driven as a subprocess by an agent harness) writes are queued asynchronously, andprocess.exit()tears the process down before a queued write reaches the pipe.This is directly reachable from the exact code path that renders the reported error:
handleRunCliFailureinsrc/cli.tscallsprintHumanErrorand then, under--debug,printDaemonLogTailOnError— which dumps up to 200 unbounded lines of the daemon's log — immediately beforeprocess.exit(1).Evidence (red without the fix, green with it), against the real CLI, not just an isolated repro:
SIGKILL(staledaemon.json, live-then-dead pid — the same shape as "unreachable").daemon.logpast 64KB (macOS/Linux's default pipe buffer size) and re-ranclose --debug --state-dir <dir>through a real pipedspawnSync, matching how an agent harness captures subprocess output.stderr length: 66672, trailing marker missing — truncated mid-write.I want to be upfront about the limit of this evidence: a freshly started daemon truncates its own
daemon.logto empty at startup (src/daemon/server/server-lifecycle.ts:29,fs.writeFileSync(logPath, '')), so in a plain sandbox (no simulator/device backend) the log-tail dump right after a replace is only a few dozen bytes — nowhere near 64KB. I cannot prove this exact log-tail dump is what corrupted the 3 AppControlBench transcripts; that would need a host where the fresh daemon's own startup logging (device enumeration, xcrun/adb calls, etc.) is chatty enough to approach the pipe-buffer threshold on its own, which I can't reproduce here. What I can say confidently: the mechanism is real, it lives in the CLI's shared failure-rendering path, it is reachable from this exact SESSION_NOT_FOUND-after-replace scenario, and it was previously completely unguarded — this PR closes that gap regardless of whether it's proven to be the exact historical trigger.Fix
src/utils/process-exit.ts(exitAfterFlush): drainsprocess.stdout/stderr(bounded by a 2s timeout so a stalled/broken pipe still can't hang the process) before callingprocess.exit().process.exit()call site insrc/cli.tsandsrc/bin.tsthrough it (the failure-rendering path, the parse/help/version fast-exits, and the react-devtools/web/cdp subcommand exit-code wrappers) — all share the same footgun.printDaemonLogTailOnError's dump to 64,000 bytes on top of its existing 200-line cap, as defense in depth.Tests
test/integration/daemon-replace-exit-flush.test.ts(new,node --test, follows the existingsmoke-daemon-*.test.tsharness pattern):SIGKILLs it, reruns a command against the stale state dir, asserts the CLI prints the "Replacing daemon … unreachable" notice, exits with code 1, and returns parseable--jsonwitherror.code === 'SESSION_NOT_FOUND'and anopen-mentioning hint.test/integration/support/exit-naive.ts/exit-after-flush.ts) each write an oversized payload to stderr then exit the old way vs. the fixed way, run as real piped child processes. Confirms the naive path truncates andexitAfterFlushdoesn't, independent of any daemon/device setup.Verification
pnpm check:quick(lint + typecheck): clean.node --test test/integration/daemon-replace-exit-flush.test.ts test/integration/smoke-daemon-clean.test.ts test/integration/smoke-daemon-http.test.ts: 5/5 pass.pnpm test:unit: 603/603 files pass (5290/5290 tests). One initial run showed transient timeouts from CPU contention caused by two concurrent full suite runs, and oneENOTEMPTYtemp-dir cleanup race in an unrelated test file — both reproduced as flaky-under-contention and confirmed unrelated to this change by re-running each failing file in isolation (all green).Test plan
pnpm check:quicknode --test test/integration/daemon-replace-exit-flush.test.tspnpm test:unit(clean run, 603/603 files)