fix(codex): prevent gateway crash when app-server subprocess terminates abruptly (#67947)

Fixes openclaw#67886. Handles stdin EPIPE in CodexAppServerClient by attaching an error handler, guarding writeMessage against writes after close, and aligning closeWithError cleanup with close.
This commit is contained in:
Chunyue Wang
2026-04-17 23:28:37 +08:00
committed by GitHub
parent d565c2cc34
commit 0b3d876e74
4 changed files with 47 additions and 0 deletions

View File

@@ -199,6 +199,39 @@ describe("CodexAppServerClient", () => {
expect(process.kill).toHaveBeenCalledWith("SIGKILL");
expect(process.unref).toHaveBeenCalledTimes(1);
});
it("handles stdin write errors without crashing the process", async () => {
const harness = createClientHarness();
clients.push(harness.client);
// Start a pending request so we can verify it gets properly rejected.
const pending = harness.client.request("test/method");
// Simulate the child process closing its pipe — a write to the now-dead
// stdin emits an asynchronous EPIPE error on the stream.
harness.process.stdin.destroy(Object.assign(new Error("write EPIPE"), { code: "EPIPE" }));
// The pending request must be rejected with the pipe error rather than
// an unhandled exception tearing down the gateway.
await expect(pending).rejects.toThrow("write EPIPE");
// Subsequent requests are rejected immediately (client is closed).
await expect(harness.client.request("another/method")).rejects.toThrow(
"codex app-server client is closed",
);
});
it("does not write to stdin after the child process exits", async () => {
const harness = createClientHarness();
clients.push(harness.client);
// Simulate the child process exiting.
harness.process.emit("exit", 1, null);
// A notification after exit must not attempt a write.
harness.client.notify("late/event", { data: "ignored" });
expect(harness.writes).toHaveLength(0);
});
it("reads the Codex version from the app-server user agent", () => {
expect(readCodexVersionFromUserAgent("Codex Desktop/0.118.0")).toBe("0.118.0");
expect(readCodexVersionFromUserAgent("openclaw/0.118.0 (macOS; test)")).toBe("0.118.0");

View File

@@ -74,6 +74,13 @@ export class CodexAppServerClient {
),
);
});
// Guard against unhandled EPIPE / write-after-close errors on the stdin
// stream. When the child process terminates abruptly the pipe can break
// before the "exit" event fires, so a pending writeMessage() produces an
// asynchronous error on stdin that would otherwise crash the gateway.
child.stdin.on?.("error", (error) =>
this.closeWithError(error instanceof Error ? error : new Error(String(error))),
);
}
static start(options?: Partial<CodexAppServerStartOptions>): CodexAppServerClient {
@@ -212,6 +219,9 @@ export class CodexAppServerClient {
}
private writeMessage(message: RpcRequest | RpcResponse): void {
if (this.closed) {
return;
}
this.child.stdin.write(`${JSON.stringify(message)}\n`);
}
@@ -300,7 +310,9 @@ export class CodexAppServerClient {
return;
}
this.closed = true;
this.lines.close();
this.rejectPendingRequests(error);
closeCodexAppServerTransport(this.child);
}
private rejectPendingRequests(error: Error): void {

View File

@@ -4,6 +4,7 @@ export type CodexAppServerTransport = {
end?: () => unknown;
destroy?: () => unknown;
unref?: () => unknown;
on?: (event: "error", listener: (error: Error) => void) => unknown;
};
stdout: NodeJS.ReadableStream & {
destroy?: () => unknown;