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>
This commit is contained in:
Momo
2026-07-07 18:51:55 +08:00
committed by GitHub
parent 69b9932fae
commit 695fa4e112
2 changed files with 37 additions and 4 deletions
+34 -4
View File
@@ -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");
+3
View File
@@ -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 };
}