Skip to content

Commit bda9070

Browse files
fix(codex): settle stream error only after child exits to preserve session guard lifecycle
1 parent 7c02ef1 commit bda9070

2 files changed

Lines changed: 11 additions & 4 deletions

File tree

extensions/codex/src/node-cli-sessions.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,7 @@ describe("runCodexExecResume stream error handling", () => {
327327
child?.stdout?.emit("error", new Error("synthetic parent stdout read failure"));
328328
await expect(handlePromise).rejects.toThrow("synthetic parent stdout read failure");
329329
expect(child?.killed).toBe(true);
330+
expect(child?.exitCode !== null || child?.signalCode !== null).toBe(true);
330331
} finally {
331332
vi.doUnmock("node:child_process");
332333
vi.resetModules();
@@ -355,6 +356,7 @@ describe("runCodexExecResume stream error handling", () => {
355356
child?.stderr?.emit("error", new Error("synthetic parent stderr read failure"));
356357
await expect(handlePromise).rejects.toThrow("synthetic parent stderr read failure");
357358
expect(child?.killed).toBe(true);
359+
expect(child?.exitCode !== null || child?.signalCode !== null).toBe(true);
358360
} finally {
359361
vi.doUnmock("node:child_process");
360362
vi.resetModules();

extensions/codex/src/node-cli-sessions.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -294,21 +294,23 @@ async function runCodexExecResume(params: {
294294
child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk));
295295
child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk));
296296
child.stdin.end(params.prompt);
297+
let streamError: Error | undefined;
297298
const exitCode = await new Promise<number | null>((resolve, reject) => {
298299
child.on("error", reject);
299300
child.on("exit", (code) => resolve(code));
300301
// Guard against unhandled EPIPE / read-failure errors on the parent-side
301302
// stdout/stderr Readable. When the child terminates abruptly the pipe
302303
// can break before the "exit" event fires, so the stream layer emits
303304
// an asynchronous "error" that would otherwise crash the gateway.
305+
// Unlike the earlier pattern of rejecting immediately, we record the
306+
// error and wait for the child to exit via the existing exit-listener so
307+
// the caller's active-session guard is not released while the child
308+
// process is still running.
304309
const routeStreamError = (error: unknown) => {
305-
// Kill the child before surfacing the stream error so the caller's
306-
// active-session guard is not released while the child is still running
307-
// and the process does not become orphaned.
310+
streamError = error instanceof Error ? error : new Error(String(error));
308311
child.kill("SIGTERM");
309312
const sigkill = setTimeout(() => child.kill("SIGKILL"), 2_000);
310313
sigkill.unref();
311-
reject(error instanceof Error ? error : new Error(String(error)));
312314
};
313315
child.stdout?.on?.("error", routeStreamError);
314316
child.stderr?.on?.("error", routeStreamError);
@@ -318,6 +320,9 @@ async function runCodexExecResume(params: {
318320
clearTimeout(forceKillTimeout);
319321
}
320322
});
323+
if (streamError) {
324+
throw streamError;
325+
}
321326
if (timedOut) {
322327
throw new Error(`codex exec resume timed out after ${String(params.timeoutMs)}ms`);
323328
}

0 commit comments

Comments
 (0)