From 695fa4e112f1e60371ec6aeadf7cca6ef86609e6 Mon Sep 17 00:00:00 2001 From: Momo <35096042+momothemage@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:51:55 +0800 Subject: [PATCH] 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> --- src/infra/process-respawn.test.ts | 38 +++++++++++++++++++++++++++---- src/infra/process-respawn.ts | 3 +++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/infra/process-respawn.test.ts b/src/infra/process-respawn.test.ts index 56a078c757fd..fe88b0b687d4 100644 --- a/src/infra/process-respawn.test.ts +++ b/src/infra/process-respawn.test.ts @@ -54,6 +54,15 @@ function clearSupervisorHints() { } } +function mockDetachedChild(pid: number) { + return { + pid, + kill: vi.fn(), + on: vi.fn(), + unref: vi.fn(), + }; +} + function expectLaunchdSupervisedWithoutKickstart(params?: { launchJobLabel?: string }) { setPlatform("darwin"); if (params?.launchJobLabel) { @@ -302,7 +311,7 @@ describe("respawnGatewayProcessForUpdate", () => { "gateway", "run", ]; - spawnMock.mockReturnValue({ pid: 5151, unref: vi.fn(), kill: vi.fn() }); + spawnMock.mockReturnValue(mockDetachedChild(5151)); const result = respawnGatewayProcessForUpdate(); @@ -329,7 +338,7 @@ describe("respawnGatewayProcessForUpdate", () => { "gateway", "run", ]; - spawnMock.mockReturnValue({ pid: 7171, unref: vi.fn(), kill: vi.fn() }); + spawnMock.mockReturnValue(mockDetachedChild(7171)); const result = respawnGatewayProcessForUpdate(); @@ -352,7 +361,7 @@ describe("respawnGatewayProcessForUpdate", () => { const entry = "/app/node_modules/.pnpm/@anthropic+sdk@1.0.0/node_modules/@anthropic/sdk/dist/index.js"; process.argv = ["/usr/local/bin/node", entry, "gateway", "run"]; - spawnMock.mockReturnValue({ pid: 8181, unref: vi.fn(), kill: vi.fn() }); + spawnMock.mockReturnValue(mockDetachedChild(8181)); respawnGatewayProcessForUpdate(); @@ -369,7 +378,7 @@ describe("respawnGatewayProcessForUpdate", () => { process.env.XPC_SERVICE_NAME = "ai.openclaw.mac"; process.execArgv = []; process.argv = ["/usr/local/bin/node", "/repo/dist/index.js", "gateway", "run"]; - spawnMock.mockReturnValue({ pid: 6161, unref: vi.fn(), kill: vi.fn() }); + spawnMock.mockReturnValue(mockDetachedChild(6161)); const result = respawnGatewayProcessForUpdate(); @@ -386,6 +395,27 @@ describe("respawnGatewayProcessForUpdate", () => { ); }); + it("registers a no-op detached child error listener before unref", () => { + clearSupervisorHints(); + setPlatform("linux"); + process.execArgv = []; + process.argv = ["/usr/local/bin/node", "/repo/dist/index.js", "gateway", "run"]; + const child = mockDetachedChild(9191); + spawnMock.mockReturnValue(child); + + const result = respawnGatewayProcessForUpdate(); + + expect(result.mode).toBe("spawned"); + expect(result.child).toBe(child); + expect(child.on).toHaveBeenCalledWith("error", expect.any(Function)); + const errorListener = child.on.mock.calls.find(([event]) => event === "error")?.[1]; + expect(() => errorListener?.(new Error("spawn ENOENT"))).not.toThrow(); + expect(child.unref).toHaveBeenCalledOnce(); + const onCallOrder = child.on.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY; + const unrefCallOrder = child.unref.mock.invocationCallOrder[0] ?? Number.NEGATIVE_INFINITY; + expect(onCallOrder).toBeLessThan(unrefCallOrder); + }); + it("exits to a managed supervisor for updates even when respawn is disabled", () => { clearSupervisorHints(); setPlatform("linux"); diff --git a/src/infra/process-respawn.ts b/src/infra/process-respawn.ts index 816e9b3a4e71..a2bd0c445d37 100644 --- a/src/infra/process-respawn.ts +++ b/src/infra/process-respawn.ts @@ -53,6 +53,9 @@ function spawnDetachedGatewayProcess(opts: GatewayRespawnOptions = {}): { detached: true, stdio: "inherit", }); + // Detached spawn failures can arrive asynchronously after spawn() returns. + // Keep this listener before unref() so the parent does not crash during handoff. + child.on("error", () => {}); child.unref(); return { child, pid: child.pid ?? undefined }; }