Skip to content

Commit 695fa4e

Browse files
fix(infra): handle detached respawn child errors (#101489)
* fix(infra): handle detached respawn child errors * docs(infra): explain detached respawn error listener * fix(infra): handle detached respawn child errors * fix(infra): handle detached respawn child errors --------- Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
1 parent 69b9932 commit 695fa4e

2 files changed

Lines changed: 37 additions & 4 deletions

File tree

src/infra/process-respawn.test.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,15 @@ function clearSupervisorHints() {
5454
}
5555
}
5656

57+
function mockDetachedChild(pid: number) {
58+
return {
59+
pid,
60+
kill: vi.fn(),
61+
on: vi.fn(),
62+
unref: vi.fn(),
63+
};
64+
}
65+
5766
function expectLaunchdSupervisedWithoutKickstart(params?: { launchJobLabel?: string }) {
5867
setPlatform("darwin");
5968
if (params?.launchJobLabel) {
@@ -302,7 +311,7 @@ describe("respawnGatewayProcessForUpdate", () => {
302311
"gateway",
303312
"run",
304313
];
305-
spawnMock.mockReturnValue({ pid: 5151, unref: vi.fn(), kill: vi.fn() });
314+
spawnMock.mockReturnValue(mockDetachedChild(5151));
306315

307316
const result = respawnGatewayProcessForUpdate();
308317

@@ -329,7 +338,7 @@ describe("respawnGatewayProcessForUpdate", () => {
329338
"gateway",
330339
"run",
331340
];
332-
spawnMock.mockReturnValue({ pid: 7171, unref: vi.fn(), kill: vi.fn() });
341+
spawnMock.mockReturnValue(mockDetachedChild(7171));
333342

334343
const result = respawnGatewayProcessForUpdate();
335344

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

357366
respawnGatewayProcessForUpdate();
358367

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

374383
const result = respawnGatewayProcessForUpdate();
375384

@@ -386,6 +395,27 @@ describe("respawnGatewayProcessForUpdate", () => {
386395
);
387396
});
388397

398+
it("registers a no-op detached child error listener before unref", () => {
399+
clearSupervisorHints();
400+
setPlatform("linux");
401+
process.execArgv = [];
402+
process.argv = ["/usr/local/bin/node", "/repo/dist/index.js", "gateway", "run"];
403+
const child = mockDetachedChild(9191);
404+
spawnMock.mockReturnValue(child);
405+
406+
const result = respawnGatewayProcessForUpdate();
407+
408+
expect(result.mode).toBe("spawned");
409+
expect(result.child).toBe(child);
410+
expect(child.on).toHaveBeenCalledWith("error", expect.any(Function));
411+
const errorListener = child.on.mock.calls.find(([event]) => event === "error")?.[1];
412+
expect(() => errorListener?.(new Error("spawn ENOENT"))).not.toThrow();
413+
expect(child.unref).toHaveBeenCalledOnce();
414+
const onCallOrder = child.on.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY;
415+
const unrefCallOrder = child.unref.mock.invocationCallOrder[0] ?? Number.NEGATIVE_INFINITY;
416+
expect(onCallOrder).toBeLessThan(unrefCallOrder);
417+
});
418+
389419
it("exits to a managed supervisor for updates even when respawn is disabled", () => {
390420
clearSupervisorHints();
391421
setPlatform("linux");

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+
// Detached spawn failures can arrive asynchronously after spawn() returns.
57+
// Keep this listener before unref() so the parent does not crash during handoff.
58+
child.on("error", () => {});
5659
child.unref();
5760
return { child, pid: child.pid ?? undefined };
5861
}

0 commit comments

Comments
 (0)