Skip to content

Commit 22e9656

Browse files
committed
fix: handle EPIPE errors on child process stdin writes
Fix three child-process stdin write paths that let async EPIPE errors escape to uncaughtException and crash the gateway. extensions/imessage/src/client.ts (the actual #75438 crash path): - Add child.stdin.on('error') listener in start() to catch async EPIPE and reject all pending requests via failAll(). - Add write callback to request() stdin.write() that rejects the specific pending request on error, instead of leaving it hanging until timeout. src/agents/mcp-stdio-transport.ts: - Fix write callback race in send(): previously resolved the promise immediately when write() returned true, then the write callback with EPIPE would fire after the promise was already fulfilled. Now always settles the promise from the write callback so the outcome is known before resolving. src/process/exec.ts: - Add stdin.on('error') before writing input so EPIPE from a prematurely-exited child is swallowed — the process exit handler reports the real status. One reporter observed a gateway crash after 10.5 hours of stable uptime — a single EPIPE on an iMessage RPC child process stdin write killed the gateway with code 1. Fixes: #75438
1 parent 5fbf406 commit 22e9656

6 files changed

Lines changed: 107 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ Docs: https://docs.openclaw.ai
3131
- Plugins/runtime-deps: prune legacy version-scoped plugin runtime-deps roots during bundled dependency repair and cover the path in Package Acceptance's upgrade-survivor matrix, so upgrades from 2026.4.x no longer leave stale per-plugin runtime trees after doctor runs. Thanks @vincentkoc.
3232
- Plugins/runtime-deps: keep Gateway startup plugin imports and runtime plugin fallback loads verify-only after startup/config repair planning, so packaged installs no longer spawn package-manager repair from hot paths after readiness. Refs #75283 and #75069. Thanks @brokemac79 and @xiaohuaxi.
3333
- Plugins/runtime-deps: treat package.json runtime-deps manifests as supersets when generated materialization metadata is absent, so bundled plugin activation stops restaging already-installed dependency subsets on every activation. Fixes #75429. (#75431) Thanks @loyur.
34+
- iMessage: add stdin write callback and error listener to IMessageRpcClient so async EPIPE from a closed child process rejects the pending request instead of crashing the gateway with uncaughtException. Fixes #75438.
35+
- MCP/stdio: settle MCP stdio transport send() from the write callback instead of resolving immediately on buffer acceptance, so async write errors reject the promise instead of being lost. Refs #75438.
36+
- Process/exec: add stdin error listener in runCommandWithTimeout so EPIPE from a prematurely-exited child is swallowed instead of escaping to uncaughtException. Refs #75438.
3437
- Voice Call/realtime: add default-off fast memory/session context for `openclaw_agent_consult`, giving live calls a bounded answer-or-miss path before the full agent consult. Fixes #71849. Thanks @amzzzzzzz.
3538
- Google Meet: interrupt Realtime provider output when local barge-in clears playback, so command-pair audio stops model speech instead of only restarting Chrome playback. Fixes #73850. (#73834) Thanks @shhtheonlyperson.
3639
- Gateway/config: cap oversized plugin-owned schemas in the full `config.schema` response so large installed plugin sets cannot balloon Gateway RSS or crash schema clients. Thanks @vincentkoc.

extensions/imessage/src/client.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,12 @@ export class IMessageRpcClient {
108108
this.closedResolve?.();
109109
});
110110

111+
// Without this listener, async EPIPE from a dead child crashes the
112+
// gateway via uncaughtException. (#75438)
113+
child.stdin.on("error", (err) => {
114+
this.failAll(err instanceof Error ? err : new Error(String(err)));
115+
});
116+
111117
child.on("close", (code, signal) => {
112118
if (code !== 0 && code !== null) {
113119
const reason = signal ? `signal ${signal}` : `code ${code}`;
@@ -180,7 +186,21 @@ export class IMessageRpcClient {
180186
});
181187
});
182188

183-
this.child.stdin.write(line);
189+
// Reject the specific pending request on write error (e.g. EPIPE)
190+
// instead of letting it hang until timeout. (#75438)
191+
this.child.stdin.write(line, (err) => {
192+
if (err) {
193+
const key = String(id);
194+
const pending = this.pending.get(key);
195+
if (pending) {
196+
if (pending.timer) {
197+
clearTimeout(pending.timer);
198+
}
199+
this.pending.delete(key);
200+
pending.reject(err instanceof Error ? err : new Error(String(err)));
201+
}
202+
}
203+
});
184204
return await response;
185205
}
186206

src/agents/mcp-stdio-transport.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,4 +137,50 @@ describe("OpenClawStdioClientTransport", () => {
137137
result: { ok: true },
138138
});
139139
});
140+
141+
it("rejects send() with EPIPE when child stdin is closed (#75438)", async () => {
142+
const child = new MockChildProcess();
143+
const brokenStdin = new PassThrough();
144+
brokenStdin.write = (_chunk: unknown, cbOrEncoding?: unknown, cb?: unknown) => {
145+
const callback =
146+
typeof cbOrEncoding === "function" ? cbOrEncoding : typeof cb === "function" ? cb : null;
147+
const err = Object.assign(new Error("write EPIPE"), { code: "EPIPE" });
148+
if (callback) {
149+
(callback as (err: Error) => void)(err);
150+
}
151+
return false;
152+
};
153+
child.stdin = brokenStdin;
154+
spawnMock.mockReturnValue(child);
155+
const { OpenClawStdioClientTransport } = await import("./mcp-stdio-transport.js");
156+
157+
const transport = new OpenClawStdioClientTransport({ command: "npx" });
158+
const started = transport.start();
159+
child.emit("spawn");
160+
await started;
161+
162+
await expect(
163+
transport.send({ jsonrpc: "2.0", id: 2, method: "ping" }),
164+
).rejects.toThrow("EPIPE");
165+
});
166+
167+
it("rejects send() when stdin.write throws synchronously (#75438)", async () => {
168+
const child = new MockChildProcess();
169+
const brokenStdin = new PassThrough();
170+
brokenStdin.write = () => {
171+
throw Object.assign(new Error("write after end"), { code: "ERR_STREAM_DESTROYED" });
172+
};
173+
child.stdin = brokenStdin;
174+
spawnMock.mockReturnValue(child);
175+
const { OpenClawStdioClientTransport } = await import("./mcp-stdio-transport.js");
176+
177+
const transport = new OpenClawStdioClientTransport({ command: "npx" });
178+
const started = transport.start();
179+
child.emit("spawn");
180+
await started;
181+
182+
await expect(
183+
transport.send({ jsonrpc: "2.0", id: 3, method: "ping" }),
184+
).rejects.toThrow("write after end");
185+
});
140186
});

src/agents/mcp-stdio-transport.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -131,16 +131,29 @@ export class OpenClawStdioClientTransport implements Transport {
131131
}
132132

133133
send(message: JSONRPCMessage): Promise<void> {
134-
return new Promise((resolve) => {
134+
return new Promise((resolve, reject) => {
135135
const stdin = this.process?.stdin;
136136
if (!stdin) {
137137
throw new Error("Not connected");
138138
}
139139
const json = serializeMessage(message);
140-
if (stdin.write(json)) {
141-
resolve();
142-
} else {
143-
stdin.once("drain", resolve);
140+
// Settle from the write callback so async EPIPE rejects instead of
141+
// escaping to uncaughtException. (#75438)
142+
try {
143+
const flushed = stdin.write(json, (err) => {
144+
if (err) {
145+
reject(err);
146+
} else {
147+
resolve();
148+
}
149+
});
150+
if (!flushed) {
151+
// Back-pressure: drain fires when the buffer empties, but the
152+
// write callback above still owns promise settlement.
153+
stdin.once("drain", () => {});
154+
}
155+
} catch (err) {
156+
reject(err instanceof Error ? err : new Error(String(err)));
144157
}
145158
});
146159
}

src/process/exec.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,22 @@ describe("runCommandWithTimeout", () => {
198198
expect(result.code).not.toBe(0);
199199
},
200200
);
201+
202+
it.runIf(process.platform !== "win32")(
203+
"swallows stdin EPIPE when child exits before input is consumed (#75438)",
204+
{ timeout: 5_000 },
205+
async () => {
206+
await loadExecModules();
207+
const result = await runCommandWithTimeout(
208+
[process.execPath, "-e", "process.exit(0)"],
209+
{
210+
timeoutMs: 3_000,
211+
input: "this input will EPIPE because the child ignores stdin\n",
212+
},
213+
);
214+
expect(result.code).toBe(0);
215+
},
216+
);
201217
});
202218

203219
describe("attachChildProcessBridge", () => {

src/process/exec.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,9 @@ export async function runCommandWithTimeout(
357357
armNoOutputTimer();
358358

359359
if (hasInput && child.stdin) {
360+
// Swallow EPIPE from a prematurely-exited child; the exit handler
361+
// reports the real status. (#75438)
362+
child.stdin.on("error", () => {});
360363
child.stdin.write(input ?? "");
361364
child.stdin.end();
362365
}

0 commit comments

Comments
 (0)