Skip to content

Commit 0a4b66e

Browse files
committed
fix(infra): add error listener to spawnDetachedGatewayProcess to prevent crash on async spawn failure
spawnDetachedGatewayProcess() calls child.unref() without attaching an error listener. If spawn() fails asynchronously (ENOMEM, missing executable), the ChildProcess emits an unhandled error event that crashes the Node parent process. Add a no-op .on("error") handler before unref() to prevent the unhandled exception. Fixes #101458
1 parent 48e77b6 commit 0a4b66e

2 files changed

Lines changed: 23 additions & 4 deletions

File tree

src/infra/process-respawn.test.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,7 @@ describe("respawnGatewayProcessForUpdate", () => {
302302
"gateway",
303303
"run",
304304
];
305-
spawnMock.mockReturnValue({ pid: 5151, unref: vi.fn(), kill: vi.fn() });
305+
spawnMock.mockReturnValue({ pid: 5151, unref: vi.fn(), kill: vi.fn(), on: vi.fn() });
306306

307307
const result = respawnGatewayProcessForUpdate();
308308

@@ -329,7 +329,7 @@ describe("respawnGatewayProcessForUpdate", () => {
329329
"gateway",
330330
"run",
331331
];
332-
spawnMock.mockReturnValue({ pid: 7171, unref: vi.fn(), kill: vi.fn() });
332+
spawnMock.mockReturnValue({ pid: 7171, unref: vi.fn(), kill: vi.fn(), on: vi.fn() });
333333

334334
const result = respawnGatewayProcessForUpdate();
335335

@@ -352,7 +352,7 @@ describe("respawnGatewayProcessForUpdate", () => {
352352
const entry =
353353
"/app/node_modules/.pnpm/@[email protected]/node_modules/@anthropic/sdk/dist/index.js";
354354
process.argv = ["/usr/local/bin/node", entry, "gateway", "run"];
355-
spawnMock.mockReturnValue({ pid: 8181, unref: vi.fn(), kill: vi.fn() });
355+
spawnMock.mockReturnValue({ pid: 8181, unref: vi.fn(), kill: vi.fn(), on: vi.fn() });
356356

357357
respawnGatewayProcessForUpdate();
358358

@@ -369,7 +369,7 @@ describe("respawnGatewayProcessForUpdate", () => {
369369
process.env.XPC_SERVICE_NAME = "ai.openclaw.mac";
370370
process.execArgv = [];
371371
process.argv = ["/usr/local/bin/node", "/repo/dist/index.js", "gateway", "run"];
372-
spawnMock.mockReturnValue({ pid: 6161, unref: vi.fn(), kill: vi.fn() });
372+
spawnMock.mockReturnValue({ pid: 6161, unref: vi.fn(), kill: vi.fn(), on: vi.fn() });
373373

374374
const result = respawnGatewayProcessForUpdate();
375375

@@ -413,4 +413,20 @@ describe("respawnGatewayProcessForUpdate", () => {
413413
expect(result.mode).toBe("failed");
414414
expect(result.detail).toContain("spawn failed");
415415
});
416+
417+
it("attaches error listener on spawned child to prevent crash on async spawn failure", () => {
418+
clearSupervisorHints();
419+
setPlatform("linux");
420+
process.execArgv = [];
421+
process.argv = ["/usr/local/bin/node", "/repo/dist/index.js", "gateway", "run"];
422+
const onMock = vi.fn();
423+
spawnMock.mockReturnValue({ pid: 9191, unref: vi.fn(), kill: vi.fn(), on: onMock });
424+
425+
const result = respawnGatewayProcessForUpdate();
426+
427+
expect(result.mode).toBe("spawned");
428+
expect(result.pid).toBe(9191);
429+
// Verify error listener is registered — prevents Node unhandled exception crash
430+
expect(onMock).toHaveBeenCalledWith("error", expect.any(Function));
431+
});
416432
});

src/infra/process-respawn.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ function spawnDetachedGatewayProcess(opts: GatewayRespawnOptions = {}): {
5353
detached: true,
5454
stdio: "inherit",
5555
});
56+
// ponytail: no-op error handler prevents Node crash on async spawn failure.
57+
// The child is detached and unref'd — there is nothing to reject.
58+
child.on("error", () => {});
5659
child.unref();
5760
return { child, pid: child.pid ?? undefined };
5861
}

0 commit comments

Comments
 (0)