fix(dev): forward run-node wrapper signals

This commit is contained in:
Peter Steinberger
2026-04-05 17:05:12 +01:00
parent 9e8151f347
commit 17521116db
3 changed files with 138 additions and 11 deletions

View File

@@ -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[];

View File

@@ -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;

View File

@@ -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<void> {
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, {