From 17521116dbfadfe3b4dfd6bbbe283cdbe819f8b4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 5 Apr 2026 17:05:12 +0100 Subject: [PATCH] fix(dev): forward run-node wrapper signals --- scripts/run-node.d.mts | 2 + scripts/run-node.mjs | 77 +++++++++++++++++++++++++++++++++----- src/infra/run-node.test.ts | 70 +++++++++++++++++++++++++++++++++- 3 files changed, 138 insertions(+), 11 deletions(-) diff --git a/scripts/run-node.d.mts b/scripts/run-node.d.mts index 4f1bc47597e..eab53d43c61 100644 --- a/scripts/run-node.d.mts +++ b/scripts/run-node.d.mts @@ -19,6 +19,7 @@ export function runNodeMain(params?: { args: string[], options: unknown, ) => { + kill?: (signal?: string) => boolean | void; on: ( event: "exit", cb: (code: number | null, signal: string | null) => void, @@ -27,6 +28,7 @@ export function runNodeMain(params?: { spawnSync?: unknown; fs?: unknown; stderr?: { write: (value: string) => void }; + process?: NodeJS.Process; execPath?: string; cwd?: string; args?: string[]; diff --git a/scripts/run-node.mjs b/scripts/run-node.mjs index b0966c01a15..a5fc14fb2a0 100644 --- a/scripts/run-node.mjs +++ b/scripts/run-node.mjs @@ -250,6 +250,15 @@ const BUILD_REASON_LABELS = { const formatBuildReason = (reason) => BUILD_REASON_LABELS[reason] ?? reason; +const SIGNAL_EXIT_CODES = { + SIGINT: 130, + SIGTERM: 143, +}; + +const isSignalKey = (signal) => Object.hasOwn(SIGNAL_EXIT_CODES, signal); + +const getSignalExitCode = (signal) => (isSignalKey(signal) ? SIGNAL_EXIT_CODES[signal] : 1); + const logRunner = (message, deps) => { if (deps.env.OPENCLAW_RUNNER_LOG === "0") { return; @@ -257,19 +266,65 @@ const logRunner = (message, deps) => { deps.stderr.write(`[openclaw] ${message}\n`); }; +const waitForSpawnedProcess = async (childProcess, deps) => { + let forwardedSignal = null; + let onSigInt; + let onSigTerm; + + const cleanupSignals = () => { + if (onSigInt) { + deps.process.off("SIGINT", onSigInt); + } + if (onSigTerm) { + deps.process.off("SIGTERM", onSigTerm); + } + }; + + const forwardSignal = (signal) => { + if (forwardedSignal) { + return; + } + forwardedSignal = signal; + try { + childProcess.kill?.(signal); + } catch { + // Best-effort only. Exit handling still happens via the child "exit" event. + } + }; + + onSigInt = () => { + forwardSignal("SIGINT"); + }; + onSigTerm = () => { + forwardSignal("SIGTERM"); + }; + + deps.process.on("SIGINT", onSigInt); + deps.process.on("SIGTERM", onSigTerm); + + try { + return await new Promise((resolve) => { + childProcess.on("exit", (exitCode, exitSignal) => { + resolve({ exitCode, exitSignal, forwardedSignal }); + }); + }); + } finally { + cleanupSignals(); + } +}; + const runOpenClaw = async (deps) => { const nodeProcess = deps.spawn(deps.execPath, ["openclaw.mjs", ...deps.args], { cwd: deps.cwd, env: deps.env, stdio: "inherit", }); - const res = await new Promise((resolve) => { - nodeProcess.on("exit", (exitCode, exitSignal) => { - resolve({ exitCode, exitSignal }); - }); - }); + const res = await waitForSpawnedProcess(nodeProcess, deps); if (res.exitSignal) { - return 1; + return getSignalExitCode(res.exitSignal); + } + if (res.forwardedSignal) { + return getSignalExitCode(res.forwardedSignal); } return res.exitCode ?? 1; }; @@ -306,6 +361,7 @@ export async function runNodeMain(params = {}) { spawnSync: params.spawnSync ?? spawnSync, fs: params.fs ?? fs, stderr: params.stderr ?? process.stderr, + process: params.process ?? process, execPath: params.execPath ?? process.execPath, cwd: params.cwd ?? process.cwd(), args: params.args ?? process.argv.slice(2), @@ -341,11 +397,12 @@ export async function runNodeMain(params = {}) { stdio: "inherit", }); - const buildRes = await new Promise((resolve) => { - build.on("exit", (exitCode, exitSignal) => resolve({ exitCode, exitSignal })); - }); + const buildRes = await waitForSpawnedProcess(build, deps); if (buildRes.exitSignal) { - return 1; + return getSignalExitCode(buildRes.exitSignal); + } + if (buildRes.forwardedSignal) { + return getSignalExitCode(buildRes.forwardedSignal); } if (buildRes.exitCode !== 0 && buildRes.exitCode !== null) { return buildRes.exitCode; diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts index 5e5aeeec12c..04ecd653c30 100644 --- a/src/infra/run-node.test.ts +++ b/src/infra/run-node.test.ts @@ -1,8 +1,9 @@ +import { EventEmitter } from "node:events"; import fsSync from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { resolveBuildRequirement, runNodeMain } from "../../scripts/run-node.mjs"; import { bundledDistPluginFile, @@ -54,6 +55,13 @@ function createExitedProcess(code: number | null, signal: string | null = null) }; } +function createFakeProcess() { + return Object.assign(new EventEmitter(), { + pid: 4242, + execPath: process.execPath, + }) as unknown as NodeJS.Process; +} + async function writeRuntimePostBuildScaffold(tmp: string): Promise { const pluginSdkAliasPath = path.join(tmp, "src", "plugin-sdk", "root-alias.cjs"); await fs.mkdir(path.dirname(pluginSdkAliasPath), { recursive: true }); @@ -341,6 +349,66 @@ describe("run-node script", () => { }); }); + it("forwards wrapper SIGTERM to the active openclaw child and returns 143", async () => { + await withTempDir(async (tmp) => { + await setupTrackedProject(tmp, { + files: { + [ROOT_SRC]: "export const value = 1;\n", + }, + oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE], + buildPaths: [DIST_ENTRY, BUILD_STAMP], + }); + + const fakeProcess = createFakeProcess(); + const child = Object.assign(new EventEmitter(), { + kill: vi.fn((signal: string) => { + queueMicrotask(() => child.emit("exit", 0, null)); + return signal; + }), + }); + const spawn = vi.fn< + ( + cmd: string, + args: string[], + options: unknown, + ) => { + kill: (signal?: string) => string; + on: (event: "exit", cb: (code: number | null, signal: string | null) => void) => void; + } + >(() => ({ + kill: (signal) => child.kill(String(signal ?? "SIGTERM")), + on: (event, cb) => { + child.on(event, cb); + }, + })); + + const exitCodePromise = runNodeMain({ + cwd: tmp, + args: ["status"], + env: { + ...process.env, + OPENCLAW_RUNNER_LOG: "0", + }, + process: fakeProcess, + spawn, + execPath: process.execPath, + }); + + fakeProcess.emit("SIGTERM"); + const exitCode = await exitCodePromise; + + expect(exitCode).toBe(143); + expect(spawn).toHaveBeenCalledWith( + process.execPath, + ["openclaw.mjs", "status"], + expect.objectContaining({ stdio: "inherit" }), + ); + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); + expect(fakeProcess.listenerCount("SIGINT")).toBe(0); + expect(fakeProcess.listenerCount("SIGTERM")).toBe(0); + }); + }); + it("rebuilds when extension sources are newer than the build stamp", async () => { await withTempDir(async (tmp) => { await setupTrackedProject(tmp, {