fix(scripts): reap startup metadata help descendants

This commit is contained in:
Vincent Koc
2026-06-20 11:36:27 +02:00
parent 93a0b5d353
commit dd29a6de52
2 changed files with 186 additions and 2 deletions
+55 -2
View File
@@ -62,6 +62,21 @@ type RootHelpRenderContext = Pick<RootHelpRenderOptions, "config" | "env">;
type Awaitable<T> = T | Promise<T>;
type SourceCommandHelpCommand = "nodes" | "secrets" | PrecomputedSubcommandHelpCommand;
type SourceCommandHelpText = Record<SourceCommandHelpCommand, string>;
type SpawnTextParentSignalState = {
done: boolean;
signal: NodeJS.Signals | null;
};
const activeSpawnTextParentSignals = new Set<SpawnTextParentSignalState>();
function maybeReraiseSpawnTextParentSignal(signal: NodeJS.Signals): void {
for (const state of activeSpawnTextParentSignals) {
if (state.signal === null || !state.done) {
return;
}
}
process.kill(process.pid, signal);
}
function resolveRootHelpBundleIdentity(
distDirOverride: string = distDir,
@@ -312,6 +327,9 @@ async function spawnText(
let waitingForKillGrace = false;
let childClosedResult: { code: number | null; signal: NodeJS.Signals | null } | null = null;
let killTimer: ReturnType<typeof setTimeout> | undefined;
let parentSignalPending: NodeJS.Signals | null = null;
const parentSignalState: SpawnTextParentSignalState = { done: false, signal: null };
activeSpawnTextParentSignals.add(parentSignalState);
const parentSignalHandlers: { handler: () => void; signal: NodeJS.Signals }[] = [];
const cleanupParentSignalHandlers = () => {
for (const { signal, handler } of parentSignalHandlers) {
@@ -334,9 +352,28 @@ async function spawnText(
};
const relayParentSignal = (signal: NodeJS.Signals) => {
const handler = () => {
parentSignalPending = signal;
parentSignalState.signal = signal;
signalChild(signal);
cleanupParentSignalHandlers();
process.kill(process.pid, signal);
if (!processGroupIsAlive()) {
parentSignalState.done = true;
maybeReraiseSpawnTextParentSignal(signal);
return;
}
if (killTimer) {
clearTimeout(killTimer);
}
// Keep this timer ref'ed so parent signal relay waits long enough to
// force-kill stubborn detached descendants before re-raising.
waitingForKillGrace = true;
killTimer = setTimeout(() => {
waitingForKillGrace = false;
killTimer = undefined;
signalChild("SIGKILL");
parentSignalState.done = true;
maybeReraiseSpawnTextParentSignal(signal);
}, killGraceMs);
};
parentSignalHandlers.push({ handler, signal });
process.once(signal, handler);
@@ -363,9 +400,12 @@ async function spawnText(
}
settled = true;
clearTimeout(timeout);
if (killTimer) {
if (!parentSignalPending && killTimer) {
clearTimeout(killTimer);
}
if (!parentSignalPending) {
activeSpawnTextParentSignals.delete(parentSignalState);
}
cleanupParentSignalHandlers();
callback();
};
@@ -448,6 +488,19 @@ async function spawnText(
});
child.once("close", (code, signal) => {
const result = { code, signal };
if (parentSignalPending) {
if (processGroupIsAlive()) {
childClosedResult = result;
return;
}
if (killTimer) {
clearTimeout(killTimer);
killTimer = undefined;
}
parentSignalState.done = true;
maybeReraiseSpawnTextParentSignal(parentSignalPending);
return;
}
if (waitingForKillGrace && processGroupIsAlive()) {
childClosedResult = result;
return;
@@ -1,6 +1,8 @@
// Write Cli Startup Metadata tests cover write cli startup metadata script behavior.
import { spawn } from "node:child_process";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { describe, expect, it } from "vitest";
import { __testing, writeCliStartupMetadata } from "../../scripts/write-cli-startup-metadata.ts";
import { createScriptTestHarness } from "./test-helpers.js";
@@ -69,6 +71,21 @@ async function waitForProcessExit(pid: number, timeoutMs = 1_000): Promise<void>
throw new Error(`process ${pid} was still alive after ${timeoutMs}ms`);
}
async function waitForChildClose(
child: ReturnType<typeof spawn>,
timeoutMs = 2_000,
): Promise<{ code: number | null; signal: NodeJS.Signals | null }> {
return await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("child did not close before timeout"));
}, timeoutMs);
child.once("close", (code, signal) => {
clearTimeout(timeout);
resolve({ code, signal });
});
});
}
describe("write-cli-startup-metadata", () => {
const { createTempDir } = createScriptTestHarness();
@@ -137,6 +154,120 @@ describe("write-cli-startup-metadata", () => {
},
);
it.runIf(process.platform !== "win32")(
"waits for all command help descendants before re-raising parent signals",
async () => {
const tempRoot = createTempDir("openclaw-startup-metadata-signal-");
const fastCommandPath = path.join(tempRoot, "fast-command.mjs");
const fastReadyPath = path.join(tempRoot, "fast-ready");
const commandPath = path.join(tempRoot, "command.mjs");
const runnerPath = path.join(tempRoot, "runner.mjs");
const grandchildPidPath = path.join(tempRoot, "grandchild.pid");
const grandchildScript = [
"process.on('SIGTERM', () => {});",
"setInterval(() => {}, 1000);",
].join("\n");
writeFixtureFile(
tempRoot,
"fast-command.mjs",
[
"import { writeFileSync } from 'node:fs';",
`writeFileSync(${JSON.stringify(fastReadyPath)}, "ready");`,
"process.on('SIGTERM', () => process.exit(0));",
"setInterval(() => {}, 1000);",
].join("\n"),
);
writeFixtureFile(
tempRoot,
"command.mjs",
[
"import { spawn } from 'node:child_process';",
"import { writeFileSync } from 'node:fs';",
`const grandchild = spawn(process.execPath, ["--eval", ${JSON.stringify(
grandchildScript,
)}], { stdio: "ignore" });`,
`writeFileSync(${JSON.stringify(grandchildPidPath)}, String(grandchild.pid));`,
"process.on('SIGTERM', () => process.exit(0));",
"setInterval(() => {}, 1000);",
].join("\n"),
);
writeFixtureFile(
tempRoot,
"runner.mjs",
[
`const { __testing } = await import(${JSON.stringify(
pathToFileURL(path.resolve("scripts/write-cli-startup-metadata.ts")).href,
)});`,
"void __testing.spawnText(",
` [${JSON.stringify(fastCommandPath)}],`,
" {",
` cwd: ${JSON.stringify(tempRoot)},`,
" env: process.env,",
" failureMessage: 'fast render failed',",
" killGraceMs: 100,",
" maxOutputBytes: 1024,",
" timeoutMs: 30_000,",
" },",
").catch(() => undefined);",
"void __testing.spawnText(",
` [${JSON.stringify(commandPath)}],`,
" {",
` cwd: ${JSON.stringify(tempRoot)},`,
" env: process.env,",
" failureMessage: 'render failed',",
" killGraceMs: 100,",
" maxOutputBytes: 1024,",
" timeoutMs: 30_000,",
" },",
").catch(() => undefined);",
].join("\n"),
);
const runner = spawn(process.execPath, ["--import", "tsx", runnerPath], {
cwd: process.cwd(),
stdio: "ignore",
});
let grandchildPid = 0;
try {
const deadline = Date.now() + 1_000;
while (Date.now() < deadline) {
try {
grandchildPid = Number(readFileSync(grandchildPidPath, "utf8"));
} catch {}
let fastReady = false;
try {
fastReady = readFileSync(fastReadyPath, "utf8") === "ready";
} catch {}
if (fastReady && grandchildPid > 0 && processIsAlive(grandchildPid)) {
break;
}
await new Promise((resolve) => {
setTimeout(resolve, 10);
});
}
expect(readFileSync(fastReadyPath, "utf8")).toBe("ready");
expect(grandchildPid).toBeGreaterThan(0);
expect(processIsAlive(grandchildPid)).toBe(true);
runner.kill("SIGTERM");
await expect(waitForChildClose(runner)).resolves.toEqual({
code: null,
signal: "SIGTERM",
});
await waitForProcessExit(grandchildPid, 2_000);
} finally {
if (runner.pid && processIsAlive(runner.pid)) {
runner.kill("SIGKILL");
}
if (grandchildPid > 0 && processIsAlive(grandchildPid)) {
process.kill(grandchildPid, "SIGKILL");
}
}
},
);
it("writes startup metadata with populated root help text when dist falls back to source rendering", async () => {
const tempRoot = createTempDir("openclaw-startup-metadata-");
const distDir = path.join(tempRoot, "dist");