diff --git a/scripts/write-cli-startup-metadata.ts b/scripts/write-cli-startup-metadata.ts index f3c5c3bd80e0..e7815b2237af 100644 --- a/scripts/write-cli-startup-metadata.ts +++ b/scripts/write-cli-startup-metadata.ts @@ -62,6 +62,21 @@ type RootHelpRenderContext = Pick; type Awaitable = T | Promise; type SourceCommandHelpCommand = "nodes" | "secrets" | PrecomputedSubcommandHelpCommand; type SourceCommandHelpText = Record; +type SpawnTextParentSignalState = { + done: boolean; + signal: NodeJS.Signals | null; +}; + +const activeSpawnTextParentSignals = new Set(); + +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 | 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; diff --git a/test/scripts/write-cli-startup-metadata.test.ts b/test/scripts/write-cli-startup-metadata.test.ts index 9c924411e842..a6df1a03230d 100644 --- a/test/scripts/write-cli-startup-metadata.test.ts +++ b/test/scripts/write-cli-startup-metadata.test.ts @@ -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 throw new Error(`process ${pid} was still alive after ${timeoutMs}ms`); } +async function waitForChildClose( + child: ReturnType, + 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");