mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-18 06:44:43 +00:00
* fix(imessage): handle stdout/stderr stream errors in the RPC client child A dead imsg RPC helper can emit an async error on any of its stdio streams. On a raw stream an unhandled 'error' event throws and surfaces as an uncaughtException, crashing the gateway. #75438 added this guard for stdin but left stdout/stderr — on the same long-lived child — unguarded. Route stdout/stderr stream errors through the existing failAll path via a shared failFromStreamError helper, mirroring the stdin handler. Add a regression test that emits 'error' on each stream and asserts the child does not throw and in-flight requests reject cleanly. * fix(imessage): terminate failed RPC transports * test(imessage): exercise real stream failure --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
116 lines
3.9 KiB
TypeScript
116 lines
3.9 KiB
TypeScript
// iMessage tests cover the RPC client child-process stream error handling.
|
|
import { EventEmitter } from "node:events";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const spawnMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("node:child_process", () => ({
|
|
spawn: spawnMock,
|
|
}));
|
|
|
|
// A dead imsg helper can emit an async `error` on any of its stdio streams. On
|
|
// a raw EventEmitter an unhandled `error` throws synchronously, which in the
|
|
// real gateway surfaces as an uncaughtException and crashes the process (#75438
|
|
// covered stdin only). The mock child mirrors that stdio shape so we can assert
|
|
// each stream's `error` is caught and routed to failAll.
|
|
type MockChild = EventEmitter & {
|
|
stdout: EventEmitter;
|
|
stderr: EventEmitter;
|
|
stdin: EventEmitter & {
|
|
write: (line: string, cb?: (err?: Error | null) => void) => boolean;
|
|
end: () => void;
|
|
};
|
|
killed: boolean;
|
|
kill: ReturnType<typeof vi.fn>;
|
|
};
|
|
|
|
function createMockChild(): MockChild {
|
|
const child = new EventEmitter() as MockChild;
|
|
child.stdout = new EventEmitter();
|
|
child.stderr = new EventEmitter();
|
|
const stdin = new EventEmitter() as MockChild["stdin"];
|
|
// Resolve every write cleanly so the pending request only settles via the
|
|
// stream error path under test.
|
|
stdin.write = (_line, cb) => {
|
|
cb?.(null);
|
|
return true;
|
|
};
|
|
stdin.end = () => {};
|
|
child.stdin = stdin;
|
|
child.killed = false;
|
|
child.kill = vi.fn(() => {
|
|
child.killed = true;
|
|
return true;
|
|
});
|
|
return child;
|
|
}
|
|
|
|
describe("IMessageRpcClient child stream error handling", () => {
|
|
let child: MockChild;
|
|
|
|
beforeEach(() => {
|
|
// start() refuses to spawn under a test env; clear the markers so the real
|
|
// spawn/listener wiring runs against the mock child.
|
|
vi.stubEnv("NODE_ENV", "development");
|
|
vi.stubEnv("VITEST", "");
|
|
child = createMockChild();
|
|
spawnMock.mockReset().mockReturnValue(child);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
vi.resetModules();
|
|
});
|
|
|
|
it.each(["stdout", "stderr", "stdin"] as const)(
|
|
"catches a %s stream error and rejects in-flight requests instead of crashing",
|
|
async (streamName) => {
|
|
const { IMessageRpcClient } = await import("./client.js");
|
|
const client = new IMessageRpcClient({ cliPath: "imsg" });
|
|
await client.start();
|
|
|
|
const pending = client.request("ping", {}, { timeoutMs: 0 });
|
|
// Keep the rejection from surfacing as an unhandled rejection before we
|
|
// assert on it.
|
|
pending.catch(() => {});
|
|
|
|
const streamError = new Error(`${streamName} broke`);
|
|
expect(() => child[streamName].emit("error", streamError)).not.toThrow();
|
|
|
|
await expect(pending).rejects.toThrow(`${streamName} broke`);
|
|
await expect(client.waitForClose()).resolves.toBeUndefined();
|
|
expect(child.kill).toHaveBeenCalledOnce();
|
|
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
|
|
|
await client.stop();
|
|
},
|
|
);
|
|
|
|
it("settles the client after a real child stdout stream failure", async () => {
|
|
const childProcess =
|
|
await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
|
const realChild = childProcess.spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
});
|
|
spawnMock.mockReturnValueOnce(realChild);
|
|
const { IMessageRpcClient } = await import("./client.js");
|
|
const client = new IMessageRpcClient({ cliPath: "imsg" });
|
|
await client.start();
|
|
|
|
try {
|
|
const pending = client.request("ping", {}, { timeoutMs: 0 });
|
|
pending.catch(() => {});
|
|
realChild.stdout.destroy(new Error("real stdout failure"));
|
|
|
|
await expect(pending).rejects.toThrow("real stdout failure");
|
|
await expect(client.waitForClose()).resolves.toBeUndefined();
|
|
expect(realChild.killed).toBe(true);
|
|
} finally {
|
|
if (!realChild.killed) {
|
|
realChild.kill("SIGTERM");
|
|
}
|
|
await client.stop();
|
|
}
|
|
});
|
|
});
|