fix(codex): keep runCodexExecResume alive on stdout/stderr pipe errors#102127
fix(codex): keep runCodexExecResume alive on stdout/stderr pipe errors#102127wangmiao0668000666 wants to merge 5 commits into
Conversation
The Codex node-cli-sessions runCodexExecResume helper spawns the CLI with
stdio: ["pipe", "pipe", "pipe"] and registers data handlers on stdout and
stderr, but neither stream had its own error listener. A parent-side
pipe break (gateway teardown, kernel RST, child SIGKILL mid-write) would
fire an unhandled "error" event on the stream and crash the gateway.
Route both streams into the existing child.on("error", reject) promise so
the upstream codex.cli.session.resume command handler surfaces a clear
rejection. Cover the new behavior with two real-spawn tests that emit a
synthetic "error" on the real parent-side stdout/stderr readable.
|
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. |
|
Codex review: needs maintainer review before merge. Reviewed July 8, 2026, 10:36 PM ET / 02:36 UTC. Summary PR surface: Source +37, Tests +147. Total +184 across 2 files. Reproducibility: yes. source-reproducible: current main and Review metrics: 1 noteworthy metric.
Root-cause cluster Members:
Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything. Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Rank-up moves:
Risk before merge
Maintainer options:
Next step before merge
Maintainer decision needed
Security Review detailsBest possible solution: Land the focused Do we have a high-confidence way to reproduce the issue? Yes, source-reproducible: current main and Is this the best way to solve the issue? Yes. The patch is the narrow owner-boundary fix for this direct AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against 30b3a7cdeda9. Label changesLabel justifications:
Evidence reviewedPR surface: Source +37, Tests +147. Total +184 across 2 files. View PR surface stats
What I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
Review history (8 earlier review cycles)
|
|
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. |
…ssion guard lifecycle
|
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. |
…ExecResume When a broken pipe fires the error event on both stdout and stderr back-to-back, routeStreamError was calling child.kill(SIGTERM) twice and arming a redundant SIGKILL grace timer. Add a streamKillArmed flag so the kill path runs once per child, and clear the SIGKILL timer in the existing finally block so it does not fire after the child has already exited. Adds a burst test that wraps child.kill, fires both streams, and asserts exactly one SIGTERM landed.
…utor-return The burst-dedup test awaited `new Promise<void>((resolve) => setTimeout(() => resolve(), 100))`. The arrow-body `() => resolve()` is flagged by eslint(no-promise-executor-return) because the implicit `return resolve()` is read as "return in Promise executor". Bracketing the body so resolve() is a statement (not an expression) clears the rule while keeping the test behavior identical. Caught by CI check-lint on the round-1 push.
|
Closing this myself to trim the queue. Keeping runCodexExecResume alive on stdout/stderr pipe errors touches session-state and availability semantics (double merge-risk flag) and needs owner-level judgment on the Codex harness contract; after 10 days without maintainer pickup I'll defer to however the owners want to handle pipe failures. Happy to reopen or rework with owner guidance — branch stays on my fork. |
Summary
child.stdout.on("error", ...)andchild.stderr.on("error", ...)to therunCodexExecResumechild-spawn path so a parent-side pipe break on either Readable is routed into the same rejection the existingchild.on("error", reject)path already handles, instead of escaping as an unhandlederroreventprocess.execPath --eval, with the spawn wrapper capturing the realChildProcessreference and emitting a syntheticerroron the real parent-sidestdout/stderrreadableWhy this change is needed
runCodexExecResume(extensions/codex/src/node-cli-sessions.ts:252) spawns the Codex CLI withstdio: ["pipe", "pipe", "pipe"]and registers adatahandler onstdoutandstderr, but neither stream has its ownerrorhandler. Node's EventEmitter contract is that anerrorevent on a stream without a registered listener becomes anuncaughtExceptionthat crashes the gateway. On a real production teardown — the parent process exiting, the kernel resetting the pipe, the child being SIGKILLed mid-write — that contract fires and takes the gateway down.This is the same pattern that Alix-007 fixed in #101596 (google-meet realtime) and #101597 (matrix deps) for sibling surfaces, and the same pattern that sunlit-deng fixed in #101777 for the Codex sandbox-exec-server subprocesses. This PR covers the parallel Codex
node-cli-sessionssurface that runs thecodex exec resumeCLI invocation.The Codex app-server transport at
extensions/codex/src/app-server/transport-stdio.ts:116already routes stream errors through theCodexAppServerClientconstructor (stdout at client.ts:145, stderr at client.ts:158, stdin EPIPE guard at client.ts:171), so that surface is covered and out of scope for this PR.Verification
node scripts/run-vitest.mjs extensions/codex/src/node-cli-sessions.test.ts— passed; 1 file, 9 tests (7 pre-existing + 2 new real-spawn stream-error tests)./node_modules/.bin/oxfmt --check --threads=1 extensions/codex/src/node-cli-sessions.ts extensions/codex/src/node-cli-sessions.test.ts— all files correctly formatted./node_modules/.bin/oxlint --tsconfig config/tsconfig/oxlint.extensions.json extensions/codex/src/node-cli-sessions.ts extensions/codex/src/node-cli-sessions.test.ts— no findingspnpm tsgo:extensions— passedpnpm tsgo:extensions:test— passed (test-typecheck gap is not bypassed)child.stdout?.on?.("error", ...)block to listen for"Xerror"instead of"error"makes the newrejects with the synthetic stream error when the child stdout pipe fails mid-runtest fail withsynthetic parent stdout read failurenot being thrown; restoring the block makes the test pass. Renaming the stderr listener has the symmetric effect on the stderr test.Evidence
The full preflight output (lint, format, typecheck, test) for this PR is captured in the per-step sections below. The decisive evidence is in ## Real behavior proof — the real child process spawned via
process.execPath --evaland the synthetic parent-side stream error that the fix catches.Real behavior proof
Behavior addressed: codex
node-cli-sessionsrunCodexExecResumechild stdout/stderr pipe errors no longer leak as unhandlederrorevents. The newchild.stdout.on("error", ...)andchild.stderr.on("error", ...)listeners callreject(...)on the surroundingnew Promise<number | null>that already hostschild.on("error", reject), so the existingrunCodexExecResumefailure path (and the upstreamcodex.cli.session.resumecommand handler) propagates a clearError("synthetic parent stdout read failure")(or the stderr variant) to the caller instead of crashing the gateway with an unhandled error event.Real environment tested: local Linux Node 22.22 source checkout. The new regression tests wrap the real
node:child_processspawn(viavi.doMock+vi.importActual) and substitute the spawned command withprocess.execPath --evalrunning a long-lived script that callsprocess.stdin.resume(), writes aready\nmarker to stderr, and then idles. This produces a real Node child with the productionstdio: ["pipe", "pipe", "pipe"]shape and real parent-side Readable streams. The wrapper records the realChildProcessin a captured array; the test thenemit("error", new Error("synthetic parent stdout read failure"))(or the stderr variant) on the real parent-side readable and asserts the surroundinghandlePromiserejects with that exact error message.Exact steps or command run after this patch:
node scripts/run-vitest.mjs extensions/codex/src/node-cli-sessions.test.ts --reporter=verbose— full file passes, 9 tests:runCodexExecResume stream error handling > rejects with the synthetic stream error when the child stdout pipe fails mid-run(~170 ms)runCodexExecResume stream error handling > rejects with the synthetic stream error when the child stderr pipe fails mid-run(~180 ms)Evidence after fix (output of the verbose test run):
The stdout-pipe test asserts:
await expect(handlePromise).rejects.toThrow("synthetic parent stdout read failure")runCodexExecResumeafter it has spawned a real Node child viaprocess.execPath --evaland attached the stream-error handler in this PRspawnreturns the realChildProcessand the test emits a syntheticerroron the realchild.stdoutreadableThe stderr-pipe test is the same shape, but emits the error on the stderr readable instead of stdout.
Observed result after fix: the previously unhandled
child.stdout.on("error", ...)andchild.stderr.on("error", ...)paths are now caught inside the existingrunCodexExecResumepromise contract. The error reaches the upstreamcodex.cli.session.resumecommand handler through the existingchild.on("error", reject)rejection, which the calling command then surfaces to the gateway as a normal rejected command outcome — no unhandlederrorevent, no gateway crash.What was not tested: a real OS-level EPIPE or RST triggered by a parent process being SIGKILLed externally. Local Linux Node child processes do not always surface that condition as a stream
errorevent in the test environment, so the regression test exercises the simplererrorevent contract directly. The pattern is identical to what Alix-007 used in #101597 (matrix deps MERGED 2026-07-08) and sunlit-deng used in #101777 (codex sandbox-exec-server subprocesses, OPEN, 🐚 ready for maintainer look) — those PRs also rely on a syntheticerrorevent for their regression tests, not a kernel-level RST. The Codex app-server transport atextensions/codex/src/app-server/transport-stdio.ts:116is a different long-lived spawn that already has full stream-error coverage in theCodexAppServerClientconstructor; it is intentionally out of scope for this PR.Campaign coordination
This PR is part of the active stream-error campaign wave in 2026-07:
Collision check: no open PR found for
extensions/codex/src/node-cli-sessions.tsor for therunCodexExecResumestream-error issue. Verified viagh pr list --state all --search "node-cli-sessions" --json number,title,stateand file-levelgh pr list --state all --search "runCodexExecResume" --json number,title,state; the only fuzzy hits are unrelated test-infra PRs.