From 89984a5082cea059ae792ebff00c17709986e369 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Jul 2026 06:51:49 -0700 Subject: [PATCH] fix: preserve child process signal exit codes (#113685) Co-authored-by: Peter Steinberger --- src/cli/container-target.test.ts | 21 +++++++++++++++++++++ src/cli/container-target.ts | 3 ++- src/cli/proxy-cli.runtime.test.ts | 23 +++++++++++++++++++++++ src/cli/proxy-cli.runtime.ts | 3 ++- src/cli/subprocess-exit-code.ts | 12 ++++++++++++ 5 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 src/cli/subprocess-exit-code.ts diff --git a/src/cli/container-target.test.ts b/src/cli/container-target.test.ts index 44981068f549..377d784c2943 100644 --- a/src/cli/container-target.test.ts +++ b/src/cli/container-target.test.ts @@ -125,6 +125,27 @@ describe("maybeRunCliInContainer", () => { }); }); + it.each([ + { signal: "SIGINT" as const, exitCode: 130 }, + { signal: "SIGTERM" as const, exitCode: 143 }, + ])("preserves exit code $exitCode when the container child exits from $signal", (testCase) => { + const spawnSync = vi + .fn() + .mockReturnValueOnce({ status: 0, stdout: "true\n" }) + .mockReturnValueOnce({ status: 1, stdout: "" }) + .mockReturnValueOnce({ status: null, signal: testCase.signal }); + + expect( + maybeRunCliInContainer(["node", "openclaw", "status"], { + env: { OPENCLAW_CONTAINER: "demo" } as NodeJS.ProcessEnv, + spawnSync, + }), + ).toEqual({ + handled: true, + exitCode: testCase.exitCode, + }); + }); + it("uses OPENCLAW_CONTAINER when the flag is absent", () => { const spawnSync = vi .fn() diff --git a/src/cli/container-target.ts b/src/cli/container-target.ts index 4b4b83420487..f3bbcc30f99e 100644 --- a/src/cli/container-target.ts +++ b/src/cli/container-target.ts @@ -7,6 +7,7 @@ import { consumeRootOptionToken, FLAG_TERMINATOR } from "../infra/cli-root-optio import { resolveCliArgvInvocation } from "./argv-invocation.js"; import { scanCliRootOptions } from "./root-option-scan.js"; import { takeCliRootOptionValue } from "./root-option-value.js"; +import { resolveSubprocessExitCode } from "./subprocess-exit-code.js"; type CliContainerParseResult = | { ok: true; container: string | null; argv: string[] } @@ -321,6 +322,6 @@ export function maybeRunCliInContainer( ); return { handled: true, - exitCode: typeof result.status === "number" ? result.status : 1, + exitCode: resolveSubprocessExitCode(result.status, result.signal), }; } diff --git a/src/cli/proxy-cli.runtime.test.ts b/src/cli/proxy-cli.runtime.test.ts index 0486debaff7a..383cb37eae16 100644 --- a/src/cli/proxy-cli.runtime.test.ts +++ b/src/cli/proxy-cli.runtime.test.ts @@ -482,6 +482,29 @@ describe("proxy cli runtime", () => { expect(process.exitCode).toBe(1); }); + it.each([ + { signal: "SIGINT" as const, exitCode: 130 }, + { signal: "SIGTERM" as const, exitCode: 143 }, + ])( + "preserves exit code $exitCode when the proxied child exits from $signal", + async (testCase) => { + spawnMock.mockImplementation(() => { + const child = new EventEmitter(); + queueMicrotask(() => { + child.emit("exit", null, testCase.signal); + }); + return child; + }); + + const { runDebugProxyRunCommand } = await import("./proxy-cli.runtime.js"); + + await runDebugProxyRunCommand({ commandArgs: ["example-command"] }); + + expect(process.exitCode).toBe(testCase.exitCode); + expect(serverStopSpy).toHaveBeenCalledOnce(); + }, + ); + it("stops the proxy server and ends the session when child spawn fails", async () => { spawnMock.mockImplementation(() => { const child = new EventEmitter(); diff --git a/src/cli/proxy-cli.runtime.ts b/src/cli/proxy-cli.runtime.ts index ed716df4e8f3..16e2537b94f5 100644 --- a/src/cli/proxy-cli.runtime.ts +++ b/src/cli/proxy-cli.runtime.ts @@ -22,6 +22,7 @@ import { getDebugProxyCaptureStore, } from "../proxy-capture/store.sqlite.js"; import type { CaptureQueryPreset } from "../proxy-capture/types.js"; +import { resolveSubprocessExitCode } from "./subprocess-exit-code.js"; export async function runDebugProxyStartCommand(opts: { host?: string; port?: number }) { const settings = resolveDebugProxySettings(); @@ -108,7 +109,7 @@ export async function runDebugProxyRunCommand(opts: { }); child.once("error", reject); child.once("exit", (code, signal) => { - process.exitCode = signal ? 1 : (code ?? 1); + process.exitCode = resolveSubprocessExitCode(code, signal); resolve(); }); }); diff --git a/src/cli/subprocess-exit-code.ts b/src/cli/subprocess-exit-code.ts new file mode 100644 index 000000000000..b860cc08cae4 --- /dev/null +++ b/src/cli/subprocess-exit-code.ts @@ -0,0 +1,12 @@ +import { constants as osConstants } from "node:os"; + +export function resolveSubprocessExitCode( + exitCode: number | null | undefined, + signal: NodeJS.Signals | null | undefined, +): number { + if (typeof exitCode === "number") { + return exitCode; + } + const signalNumber = signal ? (osConstants.signals as Record)[signal] : undefined; + return typeof signalNumber === "number" ? 128 + signalNumber : 1; +}