Repository navigation
fix(opencode): prevent truncated exports during provider switching - #846
Conversation
OpenCode can exit before piped stdout drains, yielding valid exit status but truncated session/config JSON. Direct finite CLI output into a private regular file instead, await process close, enforce output limits and clean up on every path. Do not retry or repair malformed transcript data. Reproduced on OpenCode 1.18.30: 64 KiB invalid pipe output versus a complete 14.6 MB file export. Verified the affected session through the fixed production API, eleven CLI integration tests, seventy switching/provider tests and typechecks. Fixes #845
Extend the opted-in read-only probe through the real Claude, Codex and OpenCode native projectors. Confirm nonempty retained conversation without publishing target sessions or executing tools. Refs #845
|
The final live-probe commit also runs the affected large native export through decodeOpencodeConversation and the real Claude, Codex and OpenCode native-resume projectors. All retained nonempty conversation content. This is read-only conversion verification, not a claim that target CLIs were started or source/target sessions switched. Explicit new-test typechecking uses the app source alias map; a bare standalone tsc invocation otherwise resolves stale compiled parser declarations from the parent installation. |
|
CI attempt1: all11 new OpenCode CLI system tests passed, but an unchanged WebSocketSessionFeed integration case failed at line294 (expected fixture u-live, observed empty entries). Its fake manager uses Claude and does not execute the modified CLI helper. All13 tests in that file pass locally. Rerunning the failed job on the identical commit to check intermittency; no assertions/timeouts or unrelated production code have been changed. |
|
Final verification: quality-gate and minimum-node-fixture-gate are green on the identical commit after rerunning the failed job. Core/system/renderer, coverage, typechecks and distributable verification passed. The independent intermittent WebSocket bootstrap test failure is recorded in #847; no assertion/timeout changes were made here. Source export/projection verification and local81 deterministic tests remain green. Ready for review; no merge or running-app change performed. |
Main changed runOpencode after this branch was pushed. e9ac8bd (Refs #864) added an AbortSignal and a default 30 s timeout, both SIGKILL, and settles only when execFile's callback runs, so a hung empty-session import cannot block OpenCode Terminal startup or outlive stop(). This branch (#845) replaced execFile's pipe capture with spawn plus a private regular-file stdout, so OpenCode's write-then-exit cannot truncate exports. Resolution keeps both behaviors. spawn has no timeout/killSignal option, and its own signal option sends SIGTERM, which a wedged CLI can ignore. So the abort listener and the deadline are rebuilt by hand on SIGKILL. Settlement still happens only from `close`, which keeps main's rule that neither the capture nor the import payload is deleted while the child is alive. Cancellation still takes precedence and keeps the "OpenCode command cancelled" outcome that OpencodeTerminalSession treats as a normal stop. Both branches added opencodeCliSessions.system.test.ts on their own. The merged file holds the output-integrity suite and main's stop/timeout real-child cases under shared TMPDIR isolation. The stop/timeout cases now also assert that no agent-code-opencode-* capture directory remains. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S4AoJJUBex14FAEZZfHPsV
On EMFILE/ENFILE Node's spawn() returns before stdio setup: the child has no pid and no stderr stream, and `error` then `close` are emitted on the next tick. runOpencode dereferenced `child.stderr!` before installing its lifecycle listeners, so the promise rejected with a TypeError, the deadline, size guard and abort listener were never cleared, and the unlistened child `error` became an uncaught exception, which the crash hooks turn into an app exit. The error and close listeners are now attached immediately after spawn and stderr wiring is optional, so settlement and cleanup still run once, from `close`, on this path too. Refs #845 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S4AoJJUBex14FAEZZfHPsV
…and overflow The `opencode` binary Agent Code runs can be OpenCode's npm launcher, a Node script that spawns the native executable with inherited stdio and forwards only SIGINT/SIGTERM/SIGHUP. Every kill path SIGKILLed just the direct child, so the native process survived holding the stdout capture and the stderr pipe. Because settlement waits for `close`, the deadline, stop() and the output-size guard all left the promise, the capture and the import payload pending indefinitely, while the native CLI kept running (and, on overflow, writing). The merge with main made this worse than execFile: execFile's kill() destroyed stdio before signalling, the hand-built spawn kill paths did not, so even a plugin helper that merely inherited stderr could hold a timed-out call open. Finite CLI commands now start in their own process group on POSIX and share one terminate(): destroy stderr so `close` cannot be held by a descendant, then SIGKILL the group, falling back to the direct child. Settlement stays on `close` and cancellation keeps precedence. The WHY comments no longer claim that spawn lacks timeout/killSignal; they give the real reasons the handlers are owned. Refs #845 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S4AoJJUBex14FAEZZfHPsV
The regular-file capture introduced for #845 was deleted only by the async finally that runs after the command settles. Finite OpenCode CLI commands belong to no shutdown drain, so quitting during a switch, duplicate or rewind (or a main-process crash, or an orphaned child) could leave a plaintext copy of the exported transcript in the temp directory, where the old pipe transport kept it only in memory. The capture is now opened read/write and exclusive (0600), unlinked and its private directory removed before spawn. The child inherits the descriptor, not the path, and the inode is freed when the last descriptor closes. The parent keeps the fstat size guard on its handle and reads the result with positional reads, because the child's writes moved the shared file offset to end-of-file. The import payload `opencode import <path>` reads by name is out of scope and still removed by importOpencodeSession's finally. Also documents which OpenCode commands may stay on a pipe (output below one pipe buffer) and which must use this capture. Refs #845 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S4AoJJUBex14FAEZZfHPsV
…adline Main's 30 s runOpencode default (e9ac8bd, Refs #864) exists to bound a hung empty-session import at OpenCode Terminal startup. It also silently applied to the transform export (switch, duplicate, rewind, prompt listing) and projected-session import, which move whole conversations. The session behind #845 was a 14.6 MB / 804-message export whose duration was never measured, so the startup bound risked turning the fixed truncation into a timeout on the very sessions #845 unblocked. Transform export and import now pass a 5 minute deadline. The terminal startup import and the target-profile probes keep 30 s. Transforms carry no AbortSignal, so this deadline is their only bound, and it ends in a process-group SIGKILL. Refs #845 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S4AoJJUBex14FAEZZfHPsV
…eplay The missing-binary case asserted only `failed`, a prefix every runOpencode rejection carries, so a timeout or parse error would have kept it green. It now requires ENOENT. Since the merge placed mkdtemp and open between the pre-abort check and spawn, a stop() that arrives during capture setup is delivered only by the post-spawn `signal.aborted` replay. Nothing exercised that branch; deleting it would leave the child alive until the deadline. The new case aborts synchronously after the call against the SIGTERM-ignoring fixture and requires a cancelled outcome, a prompt settle, a dead child and a clean temp root. Refs #845 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S4AoJJUBex14FAEZZfHPsV
The error-listener note still said early cleanup could unlink a capture that no longer has a name; the real hazards are deleting the import payload or returning a capture its producer is still writing. Also names the upstream repository for the launcher reference and drops a history aside from the owned-handlers rationale. Refs #845 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S4AoJJUBex14FAEZZfHPsV
…plan The merge rebuilt main's timeout/cancel bounds on spawn, and the review round changed process ownership, capture lifetime and transform deadlines. The plan now records those decisions, the declined shutdown drain, and the mutation evidence behind each regression. Refs #845 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S4AoJJUBex14FAEZZfHPsV
…ounds arm The real launcher-tree tests started a 2 s command deadline at spawn but allowed 5 s for the descendant to report. On a loaded runner a correct deadline could kill the tree before it existed, and the test failed on readiness without exercising termination. The escaped fixture also inferred the launcher pid from the descendant's ppid, which is 1 once a launcher has exited, and a failed readiness wait left `stop` and `overflow` trees running until the 30 s default deadline, past the test. The tree cases now live in their own file behind a spawn seam that holds runOpencode's synchronous spawn call until the descendant reports, so readiness precedes every bound and the bounds stay tight (1 s deadline, 2 s settle margin). The test owns the launcher's ChildProcess from spawn, the launcher passes its own pid down, and cleanup aborts the command before awaiting it so runOpencode's terminate() ends an unobserved tree. Refs #845 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S4AoJJUBex14FAEZZfHPsV
…eaped terminate() sent `process.kill(-pid, 'SIGKILL')` whenever the deadline, stop() or the size guard fired before `close`. Node reaps the direct child before `exit`, but `close` also waits for stderr EOF, so the timers stay armed after the reap whenever another process still holds stderr. If that holder has left the group, the group id is free, and a late trigger would SIGKILL whatever group reused the number. A timeout or overflow followed by stop() also signalled a second time, after the first SIGKILL may already have released the id. The group is now signalled at most once, and only while the direct child is unreaped (exitCode and signalCode both null, re-checked right before signalling). After the leader has exited, terminate() only destroys stderr, which is enough to release `close`. Trade-off: a same-group member that outlives an exited leader is no longer killed; it can only append to the unlinked capture, and readCapture is bounded by the fstat size. OpenCode's npm launcher waits for its native child, so the tree the group exists for is unaffected. The WHY comment no longer claims that clearing timers at `close` prevents signalling a released pid. Refs #845 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S4AoJJUBex14FAEZZfHPsV
The detached-group comment said a wedged child runs "to its own end or to the deadline". The deadline is a timer in Agent Code's main process, so once a dev-terminal Ctrl+C kills Electron a wedged CLI in its own session has no bound at all, where the old non-detached child received the terminal's SIGINT. Packaged quits orphaned these children already. Refs #845 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S4AoJJUBex14FAEZZfHPsV
Records the reaped-leader signalling fix and its trade-off, the process-tree test readiness seam, the deadline wording, and the mutation evidence for each round-2 regression. Refs #845 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S4AoJJUBex14FAEZZfHPsV
main's stop/timeout tests (e9ac8bd) waited up to 2 s for the fake CLI to report while the timeout case's 1 s deadline was already running. During round-2 verification, at load average ~42 with most of swap in use, the stop case failed on that wait before stop() was ever called, so it said nothing about termination and would block CI on a loaded runner. Both cases now use the readiness gate the process-tree tests use, extracted into testing/spawnReadiness.ts: runOpencode's spawn() returns only after the child reports, so readiness precedes the deadline and stop(). The test owns the ChildProcess from spawn, checks SIGKILL on that handle within 2 s of the trigger instead of polling a reusable pid, and aborts the command in cleanup. No budget was simply widened. Refs #845 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S4AoJJUBex14FAEZZfHPsV
Refs #845 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S4AoJJUBex14FAEZZfHPsV
…n tests Splitting the process-tree tests out copied alive/within/waitUntil into both system files, so a fix to how the tests wait could land in one and miss the other. They now live in testing/processWait.ts; the vi.mock factories stay per file because they are hoisted. Both files also reset the spawn mock after each test. mockClear keeps queued once-implementations, so a test that failed after queuing a readiness gate or process double, before runOpencode reached spawn, handed it to the next test's first spawn and turned one failure into a cascade. Vitest 4's mockReset restores the real spawn and drops the queue. Refs #845 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S4AoJJUBex14FAEZZfHPsV
…egression The regression spied on process.kill without an implementation, so the calls went through. Under the mutation it exists to catch, signalling after the leader was reaped, it would record the negative-pid SIGKILL and also deliver it to a released group id: the hazard the fix prevents. The spy now swallows negative-pid requests and passes only positive pids (liveness probes, cleanup) through, so a regression is recorded and never delivered. Refs #845 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S4AoJJUBex14FAEZZfHPsV
release() found a tree's descendant only through the ready status. If ready() failed, there was no status, and the process-group kill cannot reach an escaped descendant, so a helper that started late outlived the test in its infinite interval. The launcher fixture now records the descendant's pid the moment spawn() returns, before the descendant initializes. release() kills that owned identity after the command has settled, whether or not readiness succeeded, and ready() checks that the reporting descendant is the one recorded. A new case forces a readiness failure on an escaped descendant that is alive but delays its report, and proves cleanup leaves no survivor. Refs #845 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S4AoJJUBex14FAEZZfHPsV
Refs #845 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S4AoJJUBex14FAEZZfHPsV
Problem
OpenCode 1.18.30 writes JSON to stdout and then explicitly exits without waiting for asynchronous pipe writes to drain. Agent Code could receive a successful exit with only a truncated prefix, so
session:switch-providerfailed while parsing JSON, before any target-specific conversion.A read-only probe of an affected native session returned 65,536 bytes of invalid JSON over a pipe. The same session returned 14,566,990 bytes of valid JSON with 804 messages when stdout was a regular file. The configured 256 MiB execFile buffer was not the cause. This PR contains no private transcript text and no session identifiers.
Fix
closeis awaited before the capture is read or released, including after post-spawn errors.db path,--version) may stay on a pipe. Anything that can exceed ~64 KiB must use this capture.Merged with main (e9ac8bd)
Main's e9ac8bd (Refs #864) had bounded every CLI call with execFile's
timeoutplus an owned abort listener, both SIGKILL, with a 30 s default. That keeps a hung empty-session import from holding OpenCode Terminal startup or survivingstop(). This branch replaced execFile withspawn, so the merge (36bf958) rebuilt both bounds by hand and kept main's invariants:close;User-visible change: a timeout now reads
OpenCode <command> failed: timed out after N msinstead of execFile'sCommand failed: .... No caller parses that text;OpencodeTerminalSessionbranches onsignal.aborted.The review found that the rebuild had lost one property of execFile's kill: destroying stdio before signalling. That is fixed below.
Process ownership and capture lifetime (review rounds)
Spawn failure without stdio. On EMFILE/ENFILE, Node's
spawn()returns with no pid and no stderr stream, then emitserroron the next tick.errorandcloselisteners are now attached right after spawn, and stderr is optional. Before this, thechild.stderr!dereference threw first, the timers leaked, and the unlistenederrorbecame an uncaught exception, which the crash hooks turn into an app exit.Process group. On POSIX, commands now start with
detached: true(withoutunref). The deadline,stop()and the size guard share oneterminate():child.stderr, soclosecannot be held open by a descendant that inherited fd 2;process.kill(-pid)), falling back tochild.kill('SIGKILL'). This happens at most once, and only while the direct child is unreaped. After Node has reaped the leader, only step 1 runs, because the group id may already belong to an unrelated group.Why: OpenCode's npm launcher (
packages/opencode/bin/opencode, v1.18.30) spawns the native binary withstdio: "inherit"and forwards only SIGINT/SIGTERM/SIGHUP. Before this change, SIGKILL reached only the launcher, the native process kept both descriptors, and none of the three bounds ever settled.Resolving the native binary instead was rejected. It would couple Agent Code to OpenCode's install layout (env override, cached copy, or platform/arch/baseline/musl packages) and would still miss helpers the native CLI starts itself.
Consequence: these children have no controlling terminal, so a dev-terminal Ctrl+C no longer reaches them directly, and the CLI runs to its own end. The deadline is not a property of the child: it is a timer in Agent Code's main process and ends with it. A Ctrl+C that kills Electron therefore leaves a wedged CLI unbounded, where the old non-detached child sat in the terminal's foreground group and received the SIGINT (which the npm launcher forwards). Packaged quits already orphaned these children before this change.
Unlinked capture. The capture is opened
wx+with mode 0600, then unlinked and its private directory removed before spawn. The child inherits the descriptor, not a path.close, the parent reads the capture with positional reads.FileHandle.readFilewould return nothing, because the child's writes moved the shared offset to end-of-file (verified).opencode import <path>reads by path still needs a name while the child runs.importOpencodeSession'sfinallystill removes it.Transform timeouts
loadOpencodeSnapshot(export for switch, duplicate, rewind and prompt listing) and the projected-session import intranscriptEngine.tsnow passtimeoutMs: 5 * 60_000. The terminal startup empty-session import and thedebug config/modelsprobes keep the 30 s default.Transform calls carry no AbortSignal, so this deadline is their only bound. When it fires, it SIGKILLs the process group (while the leader is unreaped).
The duration of a large export remains unmeasured. The 14.6 MB / 804-message live probe ran before any deadline existed. It was not re-run against real sessions in any review round.
Verification
Node 24.14.1, final head
ab5606aa. CI on this head:quality-gatepassed (11m29s) andminimum-node-fixture-gatepassed, run 34728966548.tsc -p tsconfig.node.jsonandtsc -p tsconfig.web.jsonboth clean.opencodeCliSessions.system.test.ts: 15 tests (13 on 36bf958).agent-code-opencode-output-*entry exists while an import child is running;ENOENT.stop()can fire, is owned from spawn, and must be dead by SIGKILL within 2 s of the trigger.opencodeCliSessions.processTree.system.test.ts: 7 tests on real two-process launcher trees. All sit behind the shared readiness gate (testing/spawnReadiness.ts) and use shared polling helpers (testing/processWait.ts). The cases:quality-gateandminimum-node-fixture-gatepassed.transcriptEngineandopencodeTerminalSessionunit tests: 20/20.vitest related: 396/397. The one failure was the same load-bound 5 s timeout, in a control case that never reaches the changed code.detachedon macOS by addingPOSIX_SPAWN_SETSIDto the sameposix_spawncall, so the process group does not change how children are spawned. An interleaved benchmark showed no detached penalty.timed out waiting for import child status. The gated version passed both.-pid SIGKILL;timed out waiting for escaped descendant reclaimed.Review round
Round 1
Codex (request changes) and Claude (approve with comments) reviewed 36bf958.
errorexits the appterminate())spawnlackstimeout/killSignalspawnfailedENOENT)db path/--versionare safe is undocumentedRound 2
Both reviewers re-checked 8c94da7 and confirmed every round-1 disposition. Codex requested changes; Claude approved with comments.
terminate()could SIGKILL a released, reusable process-group id after Node reaped the leader but beforeclose. A timeout or overflow followed by stop() also signalled twiceexitCodeandsignalCodeare both null; after that only stderr is destroyed. Regressions: a launcher that exits 0 while an escaped helper holds stderr (no negative-pid signal requested), and timeout followed by stop (exactly one group signal, cancellation still wins). The trade-off (a same-group member that outlives an exited leader is no longer killed) is documentedppid; no per-test timeoutopencodeCliSessions.processTree.system.test.ts, behind a spawn seam that returns only after the fixture reports, so readiness precedes every bound. The launcher passes its own pid, the test owns the launcher's ChildProcess from spawn, and each case has a 30 s timeoutstop()ran)testing/spawnReadiness.ts), own the child from spawn, check SIGKILL on the ChildProcess handle within 2 s of the trigger, and abort in cleanup. No budget was simply widenedRound 3
Both reviewers approved 3096ed2 with comments and verified the production fixes with real processes. The remaining items were test-only or record keeping.
spawn()returns.release()kills that owned identity after the command settles, whether or not readiness succeeded. A new forced-readiness-failure case for the escaped fixture proves no survivorvi.mocked(spawn).mockReset()inafterEachalive/within/waitUntilduplicated across the split filestesting/processWait.ts; the hoistedvi.mockfactories stay per fileKnown limitations
close; the read is bounded by the fstat size that enforced the limit.finally.Scope
Independent of Grok feature work. No app launch, real session import/switch/retirement, or PR merge was performed, and no database-access workaround was used. Existing dependency advisories are outside this fix. The running app keeps the old behavior until this branch is integrated and rebuilt.
Fixes #845
🤖 Generated with Claude Code
https://claude.ai/code/session_01S4AoJJUBex14FAEZZfHPsV