fix(core): resolve a child's exit from /proc when Bun never reports it
A tool call whose child process has already exited can no longer wedge a turn forever. When Bun never reports the exit, the spawner now reads it from /proc within a second, and the kill path can no longer wait unboundedly after SIGKILL.
Context
The existing-site-lifecycle canary has failed its "agent turn completes" step in ~15% of runs since Aug 26, every one of them a bash tool call that ran a browser command (replo-browser attach … && playwright-cli snapshot) and never came back. Caught live on the canary sandbox (opencode pid 686, Bun v1.3.14, kernel 6.17): the bash tool's shell had exited and sat as a Z zombie under opencode for 50+ minutes; opencode held pidfds for its other live children but none for the zombie and never called waitpid; nothing else held its pipes; the part stayed running, session/status reported idle, and opencode logged nothing further for that session. Isolated repros (48 direct shell runs, 40 through POST /session/:id/shell, 5 real bash-tool turns) never hung, so it needs the canary turn's context, most likely capture-html's in-process Chromium exiting a few seconds earlier. Bun's own 1.3.14 notes describe this class (lost pidfd exit when multiple children exit close together) as fixed for stdio: "ignore"; it evidently is not for piped, detached children.
The exit Deferred in cross-spawn-spawner.ts resolves only from Node's close event. When that never fires, handle.exitCode never resolves, and the bash tool's 120s timeout fires into an acquire/release finalizer that sends SIGTERM, awaits the same Deferred, escalates to SIGKILL after 3s, then awaits it again with no bound. Finalizers are uninterruptible, so the timeout is swallowed silently.
Why it surfaced now: the canary user's model moved from sonnet-5 to gpt-5.6-terra via the replo-agent-model flag on the same days. Sonnet never ran a browser command on that sandbox; Terra runs one in most turns. All 15 hung runs sit inside Terra windows, zero in Sonnet windows.
What changed
getExit(stat)decodes/proc/<pid>/stat: returns thewaitpid-shaped[code, signal]from field 52 when the state isZ, otherwiseundefined. Verified against the live zombie's stat line.spawnstarts a Linux-only, unref'd 1s poller alongside thecloselistener; whichever observes the exit first completes the Deferred and clears the poller. A reaped-and-gone child yieldsundefinedand the poller keeps waiting for Bun's event, which owns that case.terminate()replaces the identical SIGTERM → wait → SIGKILL → wait sequence in the scope release andhandle.kill; the wait after SIGKILL is now bounded byforceKillAfterviaEffect.timeoutOption.
Net: one source file, +76/−39 including the dedupe.
What to scrutinize
The poller resolves only on state Z, never on ENOENT, so it cannot race Bun's own reaping into a wrong status. The status decode assumes the Linux waitpid layout (status & 0x7f signal, status >> 8 code); that is what /proc documents for field 52. On macOS and Windows the poller is not created. Zombies stay unreaped until opencode restarts, which on sandboxes is every boot.
Testing done
packages/corebun test test/effect/cross-spawn-spawner.test.ts test/process/process.test.ts: 49 pass, including the newgetExitdecode test (exit code, signal, running, empty).bun run typecheckinpackages/core: clean.bun linton the changed files: only the four pre-existing warnings remain.- Fleet signal after the next pin bump:
existing-site-lifecycle"agent turn completes" failures on Terra days should go to zero; sandbox-side,Tool execute before tool=bashwith no matchingaftershould stop appearing in{job="sandbox", service_name="opencode"}.
🤖 Generated with Claude Code
Summary by cubic
Fixes the existing-site-lifecycle canary's "agent turn completes" failures by stopping a bash tool call from wedging a turn forever when Bun never reports the child's exit. The spawner now reads the exit from /proc/<pid>/stat within a second, and the kill path can no longer wait unboundedly after SIGKILL.
What changed
- The exit
Deferredresolves from Node'scloseevent or a Linux-only 1s poller that decodes field 52 of/proc/<pid>/statfor zombie children. terminate()replaces the duplicated SIGTERM → wait → SIGKILL → wait sequence; the wait after SIGKILL is bounded byforceKillAfter.- The poller resolves only on zombie state, so it cannot race Bun's own reaping into a wrong status.
- The poller only reads, so zombies remain until opencode restarts.
getExitis exported with tests covering exit codes, signals, running processes, and empty stat lines.
Testing
bun testpasses 49 tests acrosscross-spawn-spawnerandprocess, including the newgetExitdecode test.- Typecheck and lint pass except for pre-existing warnings.