diff --git a/scripts/write-cli-startup-metadata.ts b/scripts/write-cli-startup-metadata.ts index 9b91e2ca33fc..78b965f6d4fa 100644 --- a/scripts/write-cli-startup-metadata.ts +++ b/scripts/write-cli-startup-metadata.ts @@ -12,6 +12,7 @@ import fs, { import { availableParallelism, tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { toErrorObject } from "@openclaw/normalization-core/error-coercion"; import pMap from "p-map"; import type { RootHelpRenderOptions } from "../src/cli/program/root-help.js"; import type { OpenClawConfig } from "../src/config/config.js"; @@ -96,10 +97,14 @@ type ExistingCliStartupMetadata = { subcommandHelpText?: unknown; rootHelpText?: unknown; }; -type SpawnTextParentSignalState = { - done: boolean; - signal: NodeJS.Signals | null; +type RenderTaskContext = { + reportFailure: (error: unknown) => void; + signal: AbortSignal; }; +type SourceHelpRenderer = ( + renderContext: RootHelpRenderContext, + taskContext?: RenderTaskContext, +) => Awaitable; type KillableChild = { kill(signal: NodeJS.Signals): boolean; pid?: number; @@ -110,15 +115,104 @@ type RunTaskkill = ( options: { stdio: "ignore" }, ) => { error?: unknown; status?: number | null } | undefined; -const activeSpawnTextParentSignals = new Set(); +class CliStartupMetadataRenderSupervisor { + readonly #abortController = new AbortController(); + readonly #parentSignalHandlers: Array<{ handler: () => void; signal: NodeJS.Signals }> = []; + #firstFailure: Error | undefined; + #parentSignal: NodeJS.Signals | null = null; + #preserveRenderState = false; -function maybeReraiseSpawnTextParentSignal(signal: NodeJS.Signals): void { - for (const state of activeSpawnTextParentSignals) { - if (state.signal === null || !state.done) { - return; + constructor() { + const signals: NodeJS.Signals[] = + process.platform === "win32" ? ["SIGINT", "SIGTERM"] : ["SIGINT", "SIGTERM", "SIGHUP"]; + for (const signal of signals) { + const handler = () => { + this.#parentSignal ??= signal; + if (!this.#abortController.signal.aborted) { + this.#abortController.abort(new Error(`CLI startup metadata interrupted by ${signal}`)); + } + }; + this.#parentSignalHandlers.push({ handler, signal }); + process.once(signal, handler); } } - process.kill(process.pid, signal); + + get firstFailure(): Error | undefined { + return this.#firstFailure; + } + + get signal(): AbortSignal { + return this.#abortController.signal; + } + + get preserveRenderState(): boolean { + return this.#preserveRenderState; + } + + reportFailure(error: unknown): void { + if ( + error instanceof Error && + "preserveRenderState" in error && + error.preserveRenderState === true + ) { + this.#preserveRenderState = true; + } + if (this.#firstFailure || this.#parentSignal) { + return; + } + this.#firstFailure = toErrorObject(error, "CLI startup metadata render failed"); + this.#abortController.abort(this.#firstFailure); + } + + async run(render: (context: RenderTaskContext) => Awaitable): Promise { + // Register every sibling before a synchronous renderer can abort the shared group. + await Promise.resolve(); + if (this.signal.aborted) { + throw this.signal.reason ?? new Error("CLI startup metadata render aborted"); + } + try { + return await render({ + reportFailure: (error) => this.reportFailure(error), + signal: this.signal, + }); + } catch (error) { + this.reportFailure(error); + throw error; + } + } + + finish( + primaryFailure: unknown, + cleanupError?: unknown, + preservedStateDir?: string, + ): never | void { + for (const { signal, handler } of this.#parentSignalHandlers) { + process.off(signal, handler); + } + this.#parentSignalHandlers.length = 0; + if (this.#parentSignal) { + process.kill(process.pid, this.#parentSignal); + return; + } + const failure = + this.#firstFailure ?? + (primaryFailure + ? toErrorObject(primaryFailure, "CLI startup metadata render failed") + : undefined); + if (!failure) { + if (cleanupError) { + throw toErrorObject(cleanupError, "CLI startup metadata cleanup failed"); + } + return; + } + if (cleanupError) { + Object.assign(failure, { cleanupError }); + } + if (preservedStateDir) { + failure.message += `\nPreserved CLI startup metadata render state: ${preservedStateDir}`; + } + throw failure; + } } function signalWindowsProcessTree( @@ -352,13 +446,28 @@ function withIsolatedRootHelpRenderContext( async function settleRootHelpRenderPromises( values: T, stateDir: string, + supervisor: CliStartupMetadataRenderSupervisor, ): Promise<{ -readonly [P in keyof T]: Awaited }> { - try { - return await Promise.all(values); - } finally { - await Promise.allSettled(values); - cleanupRootHelpRenderStateDir(stateDir); + const settled = await Promise.allSettled(values); + const rejected = settled.find( + (result): result is PromiseRejectedResult => result.status === "rejected", + ); + let cleanupError: unknown; + if (!supervisor.preserveRenderState) { + try { + cleanupRootHelpRenderStateDir(stateDir); + } catch (error) { + cleanupError = error; + } } + supervisor.finish( + rejected?.reason, + cleanupError, + supervisor.preserveRenderState ? stateDir : undefined, + ); + return settled.map((result) => (result as PromiseFulfilledResult).value) as { + -readonly [P in keyof T]: Awaited; + }; } function createIsolatedRootHelpRenderContext( @@ -391,6 +500,41 @@ function createIsolatedRootHelpRenderContext( return { config, env }; } +function createSpawnTextFailure(params: { + cause?: unknown; + detail?: string; + failureMessage: string; + kind: + | "aborted" + | "nonzero-exit" + | "output-limit" + | "process-tree-cleanup" + | "spawn-error" + | "stream-error" + | "timeout"; + startedAt: number; +}): Error { + const elapsedMs = Date.now() - params.startedAt; + return Object.assign( + new Error( + `${params.failureMessage}${params.detail ? `: ${params.detail}` : ""} (elapsed ${elapsedMs}ms)`, + params.cause === undefined ? undefined : { cause: params.cause }, + ), + { + code: + params.kind === "timeout" + ? "ETIMEDOUT" + : params.kind === "aborted" + ? "EABORTED" + : params.kind === "process-tree-cleanup" + ? "EPROCESSGROUP_CLEANUP_FAILED" + : "ECLI_STARTUP_METADATA_RENDER", + elapsedMs, + renderFailureKind: params.kind, + }, + ); +} + async function spawnText( args: string[], options: { @@ -399,6 +543,8 @@ async function spawnText( failureMessage: string; killGraceMs?: number; maxOutputBytes?: number; + onTerminalFailure?: (error: Error) => void; + signal?: AbortSignal; spawnProcess?: typeof spawn; timeoutMs: number; }, @@ -407,6 +553,16 @@ async function spawnText( const killGraceMs = options.killGraceMs ?? COMMAND_HELP_RENDER_KILL_GRACE_MS; const spawnProcess = options.spawnProcess ?? spawn; const useProcessGroup = process.platform !== "win32"; + const startedAt = Date.now(); + if (options.signal?.aborted) { + throw createSpawnTextFailure({ + cause: options.signal.reason, + detail: "aborted before start", + failureMessage: options.failureMessage, + kind: "aborted", + startedAt, + }); + } return await new Promise((resolve, reject) => { const child = spawnProcess(process.execPath, args, { cwd: options.cwd, @@ -417,23 +573,13 @@ async function spawnText( let stdout = ""; let stderr = ""; let outputBytes = 0; - let outputExceeded = false; - let outputStreamError: { streamName: "stdout" | "stderr"; error: Error } | undefined; let settled = false; - let timedOut = false; + let terminalFailure: Error | undefined; + let processTreeCleanupFailure: Error | undefined; let waitingForKillGrace = false; + let forceKillInFlight = 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) { - process.off(signal, handler); - } - parentSignalHandlers.length = 0; - }; const signalChild = (signal: NodeJS.Signals) => { signalCliStartupMetadataProcessTree(child, signal, { appendDiagnostic: (message) => { @@ -442,39 +588,6 @@ async function spawnText( useProcessGroup, }); }; - const relayParentSignal = (signal: NodeJS.Signals) => { - const handler = () => { - parentSignalPending = signal; - parentSignalState.signal = signal; - signalChild(signal); - cleanupParentSignalHandlers(); - 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); - }; - if (useProcessGroup) { - relayParentSignal("SIGINT"); - relayParentSignal("SIGTERM"); - relayParentSignal("SIGHUP"); - } const processGroupIsAlive = () => { if (!useProcessGroup || typeof child.pid !== "number") { return false; @@ -486,51 +599,78 @@ async function spawnText( return (error as NodeJS.ErrnoException).code === "EPERM"; } }; + const waitForProcessGroupExit = async (timeoutMs: number) => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!processGroupIsAlive()) { + return true; + } + await new Promise((resolvePoll) => { + setTimeout(resolvePoll, 25); + }); + } + return !processGroupIsAlive(); + }; + const recordTerminalFailure = (error: Error) => { + if (terminalFailure) { + return terminalFailure; + } + terminalFailure = error; + options.onTerminalFailure?.(error); + return error; + }; + const createFailure = ( + kind: Parameters[0]["kind"], + detail: string, + cause?: unknown, + ) => + createSpawnTextFailure({ + cause, + detail, + failureMessage: options.failureMessage, + kind, + startedAt, + }); + const fail = ( + kind: Parameters[0]["kind"], + detail: string, + cause?: unknown, + ) => recordTerminalFailure(createFailure(kind, detail, cause)); + const abortListener = () => { + if (settled || terminalFailure) { + return; + } + fail("aborted", "aborted after sibling failure", options.signal?.reason); + signalChild("SIGTERM"); + scheduleKill(); + }; const settle = (callback: () => void) => { if (settled) { return; } settled = true; clearTimeout(timeout); - if (!parentSignalPending && killTimer) { + if (killTimer) { clearTimeout(killTimer); } - if (!parentSignalPending) { - activeSpawnTextParentSignals.delete(parentSignalState); - } - cleanupParentSignalHandlers(); + options.signal?.removeEventListener("abort", abortListener); callback(); }; const finishClose = (result: { code: number | null; signal: NodeJS.Signals | null }) => { settle(() => { - if (outputStreamError) { - reject( - new Error( - `${options.failureMessage}: ${outputStreamError.streamName} read error: ${outputStreamError.error.message}`, - { cause: outputStreamError.error }, - ), - ); - return; - } - if (result.code === 0 && !timedOut && !outputExceeded) { + if (result.code === 0 && !terminalFailure) { resolve(stdout); return; } - const detail = stderr.trim(); - reject( - new Error( - options.failureMessage + - (outputExceeded - ? `: output exceeded ${maxOutputBytes} bytes` - : timedOut - ? `: timed out after ${options.timeoutMs}ms` - : detail - ? `: ${detail}` - : result.signal - ? `: terminated by ${result.signal}` - : ""), - ), - ); + const detail = stderr.trim() || (result.signal ? `terminated by ${result.signal}` : ""); + const failure = terminalFailure ?? createFailure("nonzero-exit", detail); + if (processTreeCleanupFailure) { + Object.assign(failure, { + preserveRenderState: true, + processTreeCleanupFailure, + }); + } + reject(failure); }); }; const scheduleKill = () => { @@ -541,38 +681,79 @@ async function spawnText( killTimer = setTimeout(() => { waitingForKillGrace = false; killTimer = undefined; + forceKillInFlight = true; signalChild("SIGKILL"); - if (childClosedResult) { - finishClose(childClosedResult); - } + const forceDrain = useProcessGroup + ? waitForProcessGroupExit(killGraceMs) + : Promise.resolve(true); + void forceDrain.then((drained) => { + forceKillInFlight = false; + if (!drained) { + processTreeCleanupFailure = Object.assign( + createFailure( + "process-tree-cleanup", + `process group did not exit within ${killGraceMs}ms after SIGKILL`, + ), + { preserveRenderState: true }, + ); + options.onTerminalFailure?.(processTreeCleanupFailure); + } + if (childClosedResult) { + finishClose(childClosedResult); + } else if (!drained) { + child.stdout.destroy(); + child.stderr.destroy(); + child.unref?.(); + finishClose({ code: null, signal: "SIGKILL" }); + } + }); }, killGraceMs); + if (useProcessGroup) { + void waitForProcessGroupExit(killGraceMs).then((drained) => { + if (!drained || !waitingForKillGrace) { + return; + } + waitingForKillGrace = false; + if (killTimer) { + clearTimeout(killTimer); + killTimer = undefined; + } + if (childClosedResult) { + finishClose(childClosedResult); + } + }); + } }; const requestStop = () => { signalChild("SIGTERM"); scheduleKill(); }; + options.signal?.addEventListener("abort", abortListener, { once: true }); + if (options.signal?.aborted) { + abortListener(); + } const failOutputStream = (streamName: "stdout" | "stderr", error: Error) => { // Keep the first stop cause: killing for a timeout or output cap can make // the stdio pipes fail secondarily while the child is shutting down. - if (outputStreamError || timedOut || outputExceeded) { + if (terminalFailure) { return; } - outputStreamError = { streamName, error }; + fail("stream-error", `${streamName} read error: ${error.message}`, error); requestStop(); }; const timeout = setTimeout(() => { - timedOut = true; + fail("timeout", `timed out after ${options.timeoutMs}ms`); requestStop(); }, options.timeoutMs); timeout.unref(); child.stdout.setEncoding("utf8"); child.stdout.on("data", (chunk: string) => { - if (outputExceeded) { + if (terminalFailure) { return; } outputBytes += Buffer.byteLength(chunk); if (outputBytes > maxOutputBytes) { - outputExceeded = true; + fail("output-limit", `output exceeded ${maxOutputBytes} bytes`); requestStop(); return; } @@ -580,12 +761,12 @@ async function spawnText( }); child.stderr.setEncoding("utf8"); child.stderr.on("data", (chunk: string) => { - if (outputExceeded) { + if (terminalFailure) { return; } outputBytes += Buffer.byteLength(chunk); if (outputBytes > maxOutputBytes) { - outputExceeded = true; + fail("output-limit", `output exceeded ${maxOutputBytes} bytes`); requestStop(); return; } @@ -598,27 +779,25 @@ async function spawnText( failOutputStream("stderr", error); }); child.once("error", (error) => { + const failure = fail( + "spawn-error", + error instanceof Error ? error.message : String(error), + error, + ); settle(() => { - reject(error); + reject(failure); }); }); 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 (code !== 0 && !terminalFailure) { + fail("nonzero-exit", stderr.trim() || (signal ? `terminated by ${signal}` : "")); } - if (waitingForKillGrace && processGroupIsAlive()) { + if (processGroupIsAlive()) { childClosedResult = result; + if (!waitingForKillGrace && !forceKillInFlight) { + requestStop(); + } return; } finishClose(result); @@ -629,6 +808,7 @@ async function spawnText( async function renderBundledRootHelpText( _distDirOverride: string = distDir, renderContext?: RootHelpRenderContext, + taskContext?: RenderTaskContext, ): Promise { if (!renderContext) { const bundledPluginsDir = existsSync(path.join(_distDirOverride, "extensions")) @@ -636,7 +816,7 @@ async function renderBundledRootHelpText( : extensionsDir; return await withIsolatedRootHelpRenderContext( bundledPluginsDir, - async (context) => await renderBundledRootHelpText(_distDirOverride, context), + async (context) => await renderBundledRootHelpText(_distDirOverride, context, taskContext), ); } const bundleIdentity = resolveCliStartupRootHelpBundleIdentity(_distDirOverride); @@ -661,13 +841,21 @@ async function renderBundledRootHelpText( // RootHelpRenderOptions marks env optional; spawnText requires one. env: renderContext.env ?? process.env, failureMessage: `Failed to render bundled root help from ${bundleIdentity.bundleName}`, + onTerminalFailure: taskContext?.reportFailure, + signal: taskContext?.signal, timeoutMs: ROOT_HELP_RENDER_TIMEOUT_MS, }); } -async function renderSourceRootHelpText(renderContext?: RootHelpRenderContext): Promise { +async function renderSourceRootHelpText( + renderContext?: RootHelpRenderContext, + taskContext?: RenderTaskContext, +): Promise { if (!renderContext) { - return await withIsolatedRootHelpRenderContext(extensionsDir, renderSourceRootHelpText); + return await withIsolatedRootHelpRenderContext( + extensionsDir, + async (context) => await renderSourceRootHelpText(context, taskContext), + ); } const moduleUrl = pathToFileURL(path.join(rootDir, "src/cli/program/root-help.ts")).href; const renderOptions = { @@ -688,21 +876,27 @@ async function renderSourceRootHelpText(renderContext?: RootHelpRenderContext): cwd: rootDir, env: renderContext.env ?? process.env, failureMessage: "Failed to render source root help", + onTerminalFailure: taskContext?.reportFailure, + signal: taskContext?.signal, timeoutMs: ROOT_HELP_RENDER_TIMEOUT_MS, }); } -async function renderSourceBrowserHelpText(renderContext: RootHelpRenderContext): Promise { +async function renderSourceBrowserHelpText( + renderContext: RootHelpRenderContext, + taskContext?: RenderTaskContext, +): Promise { // The launcher CLI boot renders byte-identical browser help to a direct // tsx source render (registerBrowserCli + configureProgramHelp) while // avoiding a tsx evaluation of the whole browser CLI import graph, which // dominated this script's wall time. - return await renderSourceCommandHelpText("browser", renderContext); + return await renderSourceCommandHelpText("browser", renderContext, taskContext); } async function renderSourceCommandHelpText( command: SourceCommandHelpCommand, renderContext: RootHelpRenderContext, + taskContext?: RenderTaskContext, ): Promise { return await spawnText(["openclaw.mjs", command, "--help"], { cwd: rootDir, @@ -711,41 +905,65 @@ async function renderSourceCommandHelpText( OPENCLAW_DISABLE_CLI_STARTUP_HELP_FAST_PATH: "1", }, failureMessage: `Failed to render source ${command} help`, + onTerminalFailure: taskContext?.reportFailure, + signal: taskContext?.signal, timeoutMs: COMMAND_HELP_RENDER_TIMEOUT_MS, }); } -async function renderSourceSecretsHelpText(renderContext: RootHelpRenderContext): Promise { - return await renderSourceCommandHelpText("secrets", renderContext); +async function renderSourceSecretsHelpText( + renderContext: RootHelpRenderContext, + taskContext?: RenderTaskContext, +): Promise { + return await renderSourceCommandHelpText("secrets", renderContext, taskContext); } -async function renderSourceNodesHelpText(renderContext: RootHelpRenderContext): Promise { - return await renderSourceCommandHelpText("nodes", renderContext); +async function renderSourceNodesHelpText( + renderContext: RootHelpRenderContext, + taskContext?: RenderTaskContext, +): Promise { + return await renderSourceCommandHelpText("nodes", renderContext, taskContext); } async function renderSourceCommandHelpTextRecord( commands: readonly SourceCommandHelpCommand[], renderContext: RootHelpRenderContext, + supervisor: CliStartupMetadataRenderSupervisor, ): Promise { - const helpTexts = await pMap( + const helpTexts: Partial> = {}; + await pMap( commands, - async (commandName) => await renderSourceCommandHelpText(commandName, renderContext), + async (commandName) => { + if (supervisor.signal.aborted) { + return; + } + try { + helpTexts[commandName] = await supervisor.run(async (taskContext) => + renderSourceCommandHelpText(commandName, renderContext, taskContext), + ); + } catch { + // Keep the mapper fulfilled so p-map waits for every active process-tree drain. + } + }, { concurrency: COMMAND_HELP_RENDER_CONCURRENCY, - stopOnError: true, + stopOnError: false, }, ); - return Object.fromEntries( - commands.map((commandName, index) => [commandName, helpTexts[index]]), - ) as SourceCommandHelpText; + if (supervisor.signal.aborted) { + throw supervisor.firstFailure ?? supervisor.signal.reason; + } + return helpTexts as SourceCommandHelpText; } async function renderSourceSubcommandHelpTextRecord( renderContext: RootHelpRenderContext, + supervisor: CliStartupMetadataRenderSupervisor, ): Promise { const commandHelpText = await renderSourceCommandHelpTextRecord( PRECOMPUTED_SUBCOMMAND_HELP_COMMANDS, renderContext, + supervisor, ); return Object.fromEntries( PRECOMPUTED_SUBCOMMAND_HELP_COMMANDS.map((commandName) => [ @@ -761,13 +979,11 @@ async function writeCliStartupMetadata(options?: { extensionsDir?: string; sourceRootDir?: string; renderBundledRootHelpText?: typeof renderBundledRootHelpText; - renderSourceRootHelpText?: (renderContext: RootHelpRenderContext) => Awaitable; - renderSourceBrowserHelpText?: (renderContext: RootHelpRenderContext) => Awaitable; - renderSourceSecretsHelpText?: (renderContext: RootHelpRenderContext) => Awaitable; - renderSourceNodesHelpText?: (renderContext: RootHelpRenderContext) => Awaitable; - renderSourceSubcommandHelpTextRecord?: ( - renderContext: RootHelpRenderContext, - ) => Awaitable; + renderSourceRootHelpText?: SourceHelpRenderer; + renderSourceBrowserHelpText?: SourceHelpRenderer; + renderSourceSecretsHelpText?: SourceHelpRenderer; + renderSourceNodesHelpText?: SourceHelpRenderer; + renderSourceSubcommandHelpTextRecord?: SourceHelpRenderer; }): Promise { const resolvedDistDir = options?.distDir ?? distDir; const resolvedOutputPath = options?.outputPath ?? outputPath; @@ -852,22 +1068,23 @@ async function writeCliStartupMetadata(options?: { existsSync(bundledPluginsDir) ? bundledPluginsDir : resolvedExtensionsDir, renderStateDir, ); + const supervisor = new CliStartupMetadataRenderSupervisor(); const rootHelpTextPromise = reusableRootHelpText ? Promise.resolve(reusableRootHelpText) - : (async () => { - try { - return await (options?.renderBundledRootHelpText ?? renderBundledRootHelpText)( - resolvedDistDir, - renderContext, - ); - } catch { - // Keep the fallback asynchronous: sibling help renders share this - // event loop, so blocking here can turn completed children into false timeouts. - return await (options?.renderSourceRootHelpText ?? renderSourceRootHelpText)( - renderContext, - ); - } - })(); + : supervisor.run(async (taskContext) => + bundleIdentity + ? (options?.renderBundledRootHelpText ?? renderBundledRootHelpText)( + resolvedDistDir, + renderContext, + taskContext, + ) + : // Missing built metadata is the only source-fallback contract. A built + // renderer failure is terminal and must cancel the whole render group. + (options?.renderSourceRootHelpText ?? renderSourceRootHelpText)( + renderContext, + taskContext, + ), + ); const hasCustomCommandRenderer = options?.renderSourceBrowserHelpText || options?.renderSourceSecretsHelpText || @@ -889,27 +1106,36 @@ async function writeCliStartupMetadata(options?: { const commandHelpTextPromise = hasCustomCommandRenderer || sourceCommandsToRender.length === 0 ? null - : renderSourceCommandHelpTextRecord(sourceCommandsToRender, renderContext); + : renderSourceCommandHelpTextRecord(sourceCommandsToRender, renderContext, supervisor); const browserHelpTextPromise = reusableBrowserHelpText ? Promise.resolve(reusableBrowserHelpText) : commandHelpTextPromise ? commandHelpTextPromise.then((commandHelpText) => commandHelpText.browser) - : Promise.resolve().then(() => - (options?.renderSourceBrowserHelpText ?? renderSourceBrowserHelpText)(renderContext), + : supervisor.run((taskContext) => + (options?.renderSourceBrowserHelpText ?? renderSourceBrowserHelpText)( + renderContext, + taskContext, + ), ); const secretsHelpTextPromise = reusableSecretsHelpText ? Promise.resolve(reusableSecretsHelpText) : commandHelpTextPromise ? commandHelpTextPromise.then((commandHelpText) => commandHelpText.secrets) - : Promise.resolve().then(() => - (options?.renderSourceSecretsHelpText ?? renderSourceSecretsHelpText)(renderContext), + : supervisor.run((taskContext) => + (options?.renderSourceSecretsHelpText ?? renderSourceSecretsHelpText)( + renderContext, + taskContext, + ), ); const nodesHelpTextPromise = reusableNodesHelpText ? Promise.resolve(reusableNodesHelpText) : commandHelpTextPromise ? commandHelpTextPromise.then((commandHelpText) => commandHelpText.nodes) - : Promise.resolve().then(() => - (options?.renderSourceNodesHelpText ?? renderSourceNodesHelpText)(renderContext), + : supervisor.run((taskContext) => + (options?.renderSourceNodesHelpText ?? renderSourceNodesHelpText)( + renderContext, + taskContext, + ), ); const subcommandHelpTextPromise = reusableSubcommandHelpText ? Promise.resolve(reusableSubcommandHelpText) @@ -923,11 +1149,11 @@ async function writeCliStartupMetadata(options?: { ]), ) as PrecomputedSubcommandHelpText, ) - : Promise.resolve().then(() => - (options?.renderSourceSubcommandHelpTextRecord ?? renderSourceSubcommandHelpTextRecord)( - renderContext, - ), - ); + : options?.renderSourceSubcommandHelpTextRecord + ? supervisor.run((taskContext) => + options.renderSourceSubcommandHelpTextRecord!(renderContext, taskContext), + ) + : renderSourceSubcommandHelpTextRecord(renderContext, supervisor); const [rootHelpText, browserHelpText, secretsHelpText, nodesHelpText, subcommandHelpText] = await settleRootHelpRenderPromises( [ @@ -938,6 +1164,7 @@ async function writeCliStartupMetadata(options?: { subcommandHelpTextPromise, ] as const, renderStateDir, + supervisor, ); mkdirSync(resolvedDistDir, { recursive: true }); @@ -986,5 +1213,4 @@ export const testing = { if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) { await writeCliStartupMetadata(); - process.exit(0); } diff --git a/test/openclaw-launcher.e2e.test.ts b/test/openclaw-launcher.e2e.test.ts index 0e02ef18dbb3..386b46f5d0a3 100644 --- a/test/openclaw-launcher.e2e.test.ts +++ b/test/openclaw-launcher.e2e.test.ts @@ -344,6 +344,11 @@ describe("openclaw launcher", () => { JSON.stringify({ rootHelpText: "PRECOMPUTED help\n" }), "utf8", ); + await fs.writeFile( + path.join(fixtureRoot, "dist", "entry.js"), + "throw new Error('root help fast path must not import runtime resource owners');\n", + "utf8", + ); const result = spawnSync(process.execPath, [path.join(fixtureRoot, "openclaw.mjs"), "--help"], { cwd: fixtureRoot, @@ -366,6 +371,11 @@ describe("openclaw launcher", () => { JSON.stringify({ [params.metadataKey]: `PRECOMPUTED ${params.command} help\n` }), "utf8", ); + await fs.writeFile( + path.join(fixtureRoot, "dist", "entry.js"), + "throw new Error('command help fast path must not import runtime resource owners');\n", + "utf8", + ); const result = spawnSync( process.execPath, @@ -390,6 +400,11 @@ describe("openclaw launcher", () => { JSON.stringify({ subcommandHelpText: { [command]: `PRECOMPUTED ${command} help\n` } }), "utf8", ); + await fs.writeFile( + path.join(fixtureRoot, "dist", "entry.js"), + "throw new Error('subcommand help fast path must not import runtime resource owners');\n", + "utf8", + ); const result = spawnSync( process.execPath, diff --git a/test/scripts/write-cli-startup-metadata.test.ts b/test/scripts/write-cli-startup-metadata.test.ts index c3247a704af2..a99a64ed69de 100644 --- a/test/scripts/write-cli-startup-metadata.test.ts +++ b/test/scripts/write-cli-startup-metadata.test.ts @@ -2,6 +2,7 @@ import { spawn } from "node:child_process"; import { EventEmitter } from "node:events"; import fs, { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { availableParallelism } from "node:os"; import path from "node:path"; import { PassThrough } from "node:stream"; import { pathToFileURL } from "node:url"; @@ -17,6 +18,18 @@ vi.mock("node:child_process", async (importOriginal) => { // These subprocess tests use explicit ready/close signals; timeout only catches broken fixtures. const LOAD_SENSITIVE_PROCESS_TIMEOUT_MS = process.env.CI ? 30_000 : 15_000; +const COMMAND_HELP_RENDER_CONCURRENCY = Math.min(8, Math.max(2, availableParallelism())); +const DEFAULT_COMMAND_HELP_NAMES = [ + "browser", + "secrets", + "nodes", + "doctor", + "gateway", + "models", + "plugins", + "sessions", + "tasks", +] as const; function writeFixtureFile(rootDir: string, relativePath: string, contents: string): void { const filePath = path.join(rootDir, relativePath); @@ -79,7 +92,7 @@ function expectedTaskkillPath(): string { function createSpawnTextChild() { return Object.assign(new EventEmitter(), { - kill: vi.fn(() => true), + kill: vi.fn((_signal?: NodeJS.Signals) => true), stderr: new PassThrough(), stdout: new PassThrough(), }); @@ -188,7 +201,9 @@ describe("write-cli-startup-metadata", () => { child.emit("close", null, "SIGTERM"); await expect(render).rejects.toMatchObject({ - message: `render failed: ${streamName} read error: ${streamName} pipe failed`, + message: expect.stringContaining( + `render failed: ${streamName} read error: ${streamName} pipe failed`, + ), cause: streamError, }); expect(child.kill).toHaveBeenCalledWith("SIGTERM"); @@ -215,6 +230,272 @@ describe("write-cli-startup-metadata", () => { await expect(render).rejects.toThrow("render failed: output exceeded 5 bytes"); }); + it("aborts and drains the default command batch before removing shared state", async () => { + const actualSpawn = ( + await vi.importActual("node:child_process") + ).spawn; + const spawnMock = vi.mocked(spawn); + const tempRoot = createTempDir("openclaw-startup-metadata-batch-failure-"); + const distDir = path.join(tempRoot, "dist"); + const extensionsDir = path.join(tempRoot, "extensions"); + const outputPath = path.join(distDir, "cli-startup-metadata.json"); + const events: string[] = []; + const children: Array & { commandName: string }> = []; + const realRmSync = fs.rmSync.bind(fs); + const removeState = vi.spyOn(fs, "rmSync").mockImplementation((target, options) => { + events.push("cleanup"); + return realRmSync(target, options); + }); + + writeStartupMetadataSourceSignatureFixture(tempRoot); + writeFixtureFile(distDir, "root-help-fixture.js", "export function outputRootHelp() {}\n"); + + spawnMock.mockImplementation((_command, args) => { + const commandName = String(args[1]); + const child = Object.assign(createSpawnTextChild(), { commandName }); + child.kill.mockImplementation((signal) => { + events.push(`kill:${commandName}:${signal}`); + queueMicrotask(() => { + events.push(`close:${commandName}`); + child.emit("close", null, signal); + }); + return true; + }); + children.push(child); + return child as unknown as ReturnType; + }); + + try { + const writePromise = testing.writeCliStartupMetadata({ + distDir, + outputPath, + extensionsDir, + sourceRootDir: tempRoot, + renderBundledRootHelpText: async () => "Usage: openclaw\n", + }); + const deadline = Date.now() + 1_000; + while (children.length < COMMAND_HELP_RENDER_CONCURRENCY && Date.now() < deadline) { + await new Promise((resolve) => setImmediate(resolve)); + } + expect(children.map((child) => child.commandName)).toEqual( + DEFAULT_COMMAND_HELP_NAMES.slice(0, COMMAND_HELP_RENDER_CONCURRENCY), + ); + + const browser = children[0]; + expect(browser).toBeDefined(); + browser?.stderr.write("browser renderer failed\n"); + browser?.emit("close", 7, null); + + const error = await writePromise.then( + () => undefined, + (reason: unknown) => reason, + ); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("Failed to render source browser help"); + expect((error as Error).message).toContain("browser renderer failed"); + expect((error as Error).message).toMatch(/browser renderer failed \(elapsed \d+ms\)/u); + expect(children.map((child) => child.commandName)).not.toContain("tasks"); + for (const child of children.slice(1)) { + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); + expect(events).toContain(`close:${child.commandName}`); + } + expect(events.at(-1)).toBe("cleanup"); + expect(existsSync(outputPath)).toBe(false); + } finally { + removeState.mockRestore(); + spawnMock.mockImplementation(actualSpawn); + } + }); + + it.runIf(process.platform !== "win32")( + "preserves shared state when a canceled process group cannot be proven dead", + async () => { + const tempRoot = createTempDir("openclaw-startup-metadata-undrained-tree-"); + const distDir = path.join(tempRoot, "dist"); + const extensionsDir = path.join(tempRoot, "extensions"); + const outputPath = path.join(distDir, "cli-startup-metadata.json"); + const child = Object.assign(createSpawnTextChild(), { pid: 123 }); + const realProcessKill = process.kill.bind(process); + const processKill = vi.spyOn(process, "kill").mockImplementation((pid, signal) => { + if (pid === -123) { + return true; + } + return realProcessKill(pid, signal); + }); + let renderStateDir = ""; + + writeStartupMetadataSourceSignatureFixture(tempRoot); + writeFixtureFile(distDir, "root-help-fixture.js", "export function outputRootHelp() {}\n"); + + try { + const writePromise = testing.writeCliStartupMetadata({ + distDir, + outputPath, + extensionsDir, + sourceRootDir: tempRoot, + renderBundledRootHelpText: async () => "Usage: openclaw\n", + renderSourceBrowserHelpText: (renderContext, taskContext) => { + renderStateDir = renderContext.env?.OPENCLAW_STATE_DIR ?? ""; + if (!taskContext) { + throw new Error("missing render task context"); + } + return testing.spawnText(["openclaw.mjs", "browser", "--help"], { + cwd: tempRoot, + env: process.env, + failureMessage: "browser render failed", + killGraceMs: 10, + maxOutputBytes: 1024, + onTerminalFailure: taskContext.reportFailure, + signal: taskContext.signal, + spawnProcess: (() => child as unknown as ReturnType) as typeof spawn, + timeoutMs: 5_000, + }); + }, + renderSourceSecretsHelpText: () => "Usage: openclaw secrets\n", + renderSourceNodesHelpText: () => "Usage: openclaw nodes\n", + renderSourceSubcommandHelpTextRecord: () => ({ + doctor: "Usage: openclaw doctor\n", + gateway: "Usage: openclaw gateway\n", + models: "Usage: openclaw models\n", + plugins: "Usage: openclaw plugins\n", + sessions: "Usage: openclaw sessions\n", + tasks: "Usage: openclaw tasks\n", + }), + }); + await new Promise((resolve) => setImmediate(resolve)); + child.stderr.write("primary browser failure\n"); + child.emit("close", 7, null); + + const error = await writePromise.then( + () => undefined, + (reason: unknown) => reason, + ); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("primary browser failure"); + expect((error as Error).message).toContain( + `Preserved CLI startup metadata render state: ${renderStateDir}`, + ); + expect(error).toMatchObject({ + preserveRenderState: true, + processTreeCleanupFailure: { + code: "EPROCESSGROUP_CLEANUP_FAILED", + }, + }); + expect(existsSync(renderStateDir)).toBe(true); + expect(existsSync(outputPath)).toBe(false); + } finally { + processKill.mockRestore(); + if (renderStateDir) { + fs.rmSync(renderStateDir, { force: true, recursive: true }); + } + } + }, + ); + + it.runIf(process.platform !== "win32")( + "cancels a default-batch sibling process tree after another command fails", + async () => { + const actualSpawn = ( + await vi.importActual("node:child_process") + ).spawn; + const spawnMock = vi.mocked(spawn); + const tempRoot = createTempDir("openclaw-startup-metadata-batch-tree-"); + const distDir = path.join(tempRoot, "dist"); + const extensionsDir = path.join(tempRoot, "extensions"); + const outputPath = path.join(distDir, "cli-startup-metadata.json"); + const grandchildPidPath = path.join(tempRoot, "grandchild.pid"); + const startedCommands: string[] = []; + const startedChildren: Array> = []; + let grandchildPid = 0; + + writeStartupMetadataSourceSignatureFixture(tempRoot); + writeFixtureFile(distDir, "root-help-fixture.js", "export function outputRootHelp() {}\n"); + + const failingScript = [ + "const { existsSync } = await import('node:fs');", + `const marker = ${JSON.stringify(grandchildPidPath)};`, + "const timer = setInterval(() => {", + " if (!existsSync(marker)) return;", + " clearInterval(timer);", + " process.stderr.write('browser sentinel failure\\n', () => process.exit(9));", + "}, 5);", + ].join("\n"); + const grandchildScript = [ + "process.on('SIGTERM', () => setTimeout(() => process.exit(0), 50));", + "setInterval(() => {}, 1000);", + ].join("\n"); + const siblingScript = [ + "const { spawn } = await import('node:child_process');", + "const { writeFileSync } = await import('node:fs');", + `const grandchild = spawn(process.execPath, ["--eval", ${JSON.stringify(grandchildScript)}], { stdio: "ignore" });`, + `writeFileSync(${JSON.stringify(grandchildPidPath)}, String(grandchild.pid));`, + "process.on('SIGTERM', () => setTimeout(() => process.exit(0), 100));", + "setInterval(() => {}, 1000);", + ].join("\n"); + const idleScript = [ + "process.on('SIGTERM', () => process.exit(0));", + "setInterval(() => {}, 1000);", + ].join("\n"); + + spawnMock.mockImplementation((_command, args, options) => { + const commandName = String(args[1]); + startedCommands.push(commandName); + const script = + commandName === "browser" + ? failingScript + : commandName === "secrets" + ? siblingScript + : idleScript; + const child = actualSpawn( + process.execPath, + ["--input-type=module", "--eval", script], + options, + ); + startedChildren.push(child); + return child; + }); + + try { + const startedAt = Date.now(); + const error = await testing + .writeCliStartupMetadata({ + distDir, + outputPath, + extensionsDir, + sourceRootDir: tempRoot, + renderBundledRootHelpText: async () => "Usage: openclaw\n", + }) + .then( + () => undefined, + (reason: unknown) => reason, + ); + + grandchildPid = Number(readFileSync(grandchildPidPath, "utf8")); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("browser sentinel failure"); + expect(Date.now() - startedAt).toBeLessThan(LOAD_SENSITIVE_PROCESS_TIMEOUT_MS); + expect(startedCommands).toHaveLength(COMMAND_HELP_RENDER_CONCURRENCY); + expect(startedCommands).not.toContain("tasks"); + await waitForProcessExit(grandchildPid); + expect(existsSync(outputPath)).toBe(false); + } finally { + spawnMock.mockImplementation(actualSpawn); + for (const child of startedChildren) { + if (child.pid && processIsAlive(child.pid)) { + try { + process.kill(-child.pid, "SIGKILL"); + } catch {} + } + } + if (grandchildPid > 0 && processIsAlive(grandchildPid)) { + try { + process.kill(grandchildPid, "SIGKILL"); + } catch {} + } + } + }, + ); + it("signals Windows command help render process trees with taskkill", () => { const childKill = vi.fn(() => true); const runTaskkill = vi.fn(() => ({ error: undefined, status: 0 })); @@ -302,6 +583,39 @@ describe("write-cli-startup-metadata", () => { }, ); + it.runIf(process.platform !== "win32")( + "drains descendants when a command leader exits nonzero", + async () => { + const tempRoot = createTempDir("openclaw-startup-metadata-nonzero-tree-"); + const markerPath = path.join(tempRoot, "grandchild.pid"); + const grandchildScript = [ + "process.on('SIGTERM', () => {});", + "setInterval(() => {}, 1000);", + ].join("\n"); + const parentScript = [ + "const { spawn } = await import('node:child_process');", + "const { writeFileSync } = await import('node:fs');", + `const grandchild = spawn(process.execPath, ["--eval", ${JSON.stringify(grandchildScript)}], { stdio: "ignore" });`, + `writeFileSync(${JSON.stringify(markerPath)}, String(grandchild.pid));`, + "process.stderr.write('leader failed\\n', () => process.exit(7));", + ].join("\n"); + + await expect( + testing.spawnText(["--input-type=module", "--eval", parentScript], { + cwd: tempRoot, + env: process.env, + failureMessage: "render failed", + killGraceMs: 25, + maxOutputBytes: 1024, + timeoutMs: 5_000, + }), + ).rejects.toThrow(/render failed: leader failed.*elapsed \d+ms/u); + + const grandchildPid = Number(readFileSync(markerPath, "utf8")); + await waitForProcessExit(grandchildPid); + }, + ); + it.runIf(process.platform !== "win32")( "waits for all command help descendants before re-raising parent signals", async () => { @@ -311,6 +625,9 @@ describe("write-cli-startup-metadata", () => { const commandPath = path.join(tempRoot, "command.mjs"); const runnerPath = path.join(tempRoot, "runner.mjs"); const grandchildPidPath = path.join(tempRoot, "grandchild.pid"); + const renderStatePath = path.join(tempRoot, "render-state.txt"); + const distDir = path.join(tempRoot, "dist"); + const outputPath = path.join(distDir, "cli-startup-metadata.json"); const grandchildScript = [ "process.on('SIGTERM', () => {});", "setInterval(() => {}, 1000);", @@ -339,6 +656,8 @@ describe("write-cli-startup-metadata", () => { "setInterval(() => {}, 1000);", ].join("\n"), ); + writeStartupMetadataSourceSignatureFixture(tempRoot); + writeFixtureFile(distDir, "root-help-fixture.js", "export function outputRootHelp() {}\n"); writeFixtureFile( tempRoot, "runner.mjs", @@ -346,28 +665,36 @@ describe("write-cli-startup-metadata", () => { `const { testing } = await import(${JSON.stringify( pathToFileURL(path.resolve("scripts/write-cli-startup-metadata.ts")).href, )});`, - "void testing.spawnText(", - ` [${JSON.stringify(fastCommandPath)}],`, - " {", + "const { writeFileSync } = await import('node:fs');", + "const renderCommand = (commandPath, failureMessage) => (context, taskContext) => {", + " if (!taskContext) throw new Error('missing render task context');", + ` writeFileSync(${JSON.stringify(renderStatePath)}, context.env.OPENCLAW_STATE_DIR);`, + " return testing.spawnText([commandPath], {", ` cwd: ${JSON.stringify(tempRoot)},`, " env: process.env,", - " failureMessage: 'fast render failed',", + " failureMessage,", " killGraceMs: 100,", " maxOutputBytes: 1024,", + " onTerminalFailure: taskContext.reportFailure,", + " signal: taskContext.signal,", " 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);", + " });", + "};", + "await testing.writeCliStartupMetadata({", + ` distDir: ${JSON.stringify(distDir)},`, + ` outputPath: ${JSON.stringify(outputPath)},`, + ` extensionsDir: ${JSON.stringify(path.join(tempRoot, "extensions"))},`, + ` sourceRootDir: ${JSON.stringify(tempRoot)},`, + " renderBundledRootHelpText: async () => 'Usage: openclaw\\n',", + ` renderSourceBrowserHelpText: renderCommand(${JSON.stringify(fastCommandPath)}, 'fast render failed'),`, + ` renderSourceSecretsHelpText: renderCommand(${JSON.stringify(commandPath)}, 'render failed'),`, + " renderSourceNodesHelpText: () => 'Usage: openclaw nodes\\n',", + " renderSourceSubcommandHelpTextRecord: () => ({", + " doctor: 'Usage: openclaw doctor\\n', gateway: 'Usage: openclaw gateway\\n',", + " models: 'Usage: openclaw models\\n', plugins: 'Usage: openclaw plugins\\n',", + " sessions: 'Usage: openclaw sessions\\n', tasks: 'Usage: openclaw tasks\\n',", + " }),", + "});", ].join("\n"), ); @@ -405,6 +732,8 @@ describe("write-cli-startup-metadata", () => { signal: "SIGTERM", }); await waitForProcessExit(grandchildPid); + const renderStateDir = readFileSync(renderStatePath, "utf8"); + expect(existsSync(renderStateDir)).toBe(false); } finally { if (runner.pid && processIsAlive(runner.pid)) { runner.kill("SIGKILL"); @@ -442,9 +771,6 @@ describe("write-cli-startup-metadata", () => { distDir, outputPath, extensionsDir, - renderBundledRootHelpText: async () => { - throw new Error("dist root help unavailable"); - }, renderSourceRootHelpText: () => "Usage: openclaw\n", renderSourceBrowserHelpText: () => "Usage: openclaw browser\n", renderSourceSecretsHelpText: () => "Usage: openclaw secrets\n", @@ -493,6 +819,49 @@ describe("write-cli-startup-metadata", () => { expect(written.subcommandHelpText.tasks).toContain("openclaw tasks"); }); + it("does not source-fallback a bundled root resource failure", async () => { + const tempRoot = createTempDir("openclaw-startup-metadata-root-resource-failure-"); + const distDir = path.join(tempRoot, "dist"); + const extensionsDir = path.join(tempRoot, "extensions"); + const outputPath = path.join(distDir, "cli-startup-metadata.json"); + const renderSourceRootHelpText = vi.fn(() => "Usage: source fallback\n"); + + writeStartupMetadataSourceSignatureFixture(tempRoot); + writeFixtureFile(distDir, "root-help-fixture.js", "export function outputRootHelp() {}\n"); + + const error = await testing + .writeCliStartupMetadata({ + distDir, + outputPath, + extensionsDir, + sourceRootDir: tempRoot, + renderBundledRootHelpText: async () => { + throw Object.assign(new Error("bundled root timed out"), { code: "ETIMEDOUT" }); + }, + renderSourceRootHelpText, + renderSourceBrowserHelpText: () => "Usage: openclaw browser\n", + renderSourceSecretsHelpText: () => "Usage: openclaw secrets\n", + renderSourceNodesHelpText: () => "Usage: openclaw nodes\n", + renderSourceSubcommandHelpTextRecord: () => ({ + doctor: "Usage: openclaw doctor\n", + gateway: "Usage: openclaw gateway\n", + models: "Usage: openclaw models\n", + plugins: "Usage: openclaw plugins\n", + sessions: "Usage: openclaw sessions\n", + tasks: "Usage: openclaw tasks\n", + }), + }) + .then( + () => undefined, + (reason: unknown) => reason, + ); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("bundled root timed out"); + expect(renderSourceRootHelpText).not.toHaveBeenCalled(); + expect(existsSync(outputPath)).toBe(false); + }); + it("selects the root-help bundle that exports the renderer", async () => { const tempRoot = createTempDir("openclaw-startup-metadata-bundle-selection-"); const distDir = path.join(tempRoot, "dist"); @@ -637,13 +1006,14 @@ describe("write-cli-startup-metadata", () => { extensionsDir, sourceRootDir: tempRoot, renderBundledRootHelpText: async () => "Usage: openclaw\n", - renderSourceBrowserHelpText: (renderContext) => { + renderSourceBrowserHelpText: async (renderContext) => { stateDir = renderContext.env?.OPENCLAW_STATE_DIR ?? ""; const sqliteDir = path.join(stateDir, "state"); mkdirSync(sqliteDir, { recursive: true }); for (const suffix of ["", "-shm", "-wal"]) { writeFileSync(path.join(sqliteDir, `openclaw.sqlite${suffix}`), "fixture", "utf8"); } + await new Promise((resolve) => setImmediate(resolve)); if (failRender) { throw new Error("browser help failed"); } @@ -684,6 +1054,63 @@ describe("write-cli-startup-metadata", () => { removeState.mockRestore(); }); + it("does not let shared-state cleanup mask the primary render failure", async () => { + const tempRoot = createTempDir("openclaw-startup-metadata-cleanup-failure-"); + const distDir = path.join(tempRoot, "dist"); + const extensionsDir = path.join(tempRoot, "extensions"); + const outputPath = path.join(distDir, "cli-startup-metadata.json"); + const cleanupFailure = new Error("cleanup failed"); + const realRmSync = fs.rmSync.bind(fs); + let renderStateDir = ""; + const removeState = vi.spyOn(fs, "rmSync").mockImplementation((target, options) => { + if (String(target) === renderStateDir) { + throw cleanupFailure; + } + return realRmSync(target, options); + }); + + writeStartupMetadataSourceSignatureFixture(tempRoot); + writeFixtureFile(distDir, "root-help-fixture.js", "export function outputRootHelp() {}\n"); + + try { + const error = await testing + .writeCliStartupMetadata({ + distDir, + outputPath, + extensionsDir, + sourceRootDir: tempRoot, + renderBundledRootHelpText: async () => "Usage: openclaw\n", + renderSourceBrowserHelpText: (renderContext) => { + renderStateDir = renderContext.env?.OPENCLAW_STATE_DIR ?? ""; + throw new Error("primary browser failure"); + }, + renderSourceSecretsHelpText: () => "Usage: openclaw secrets\n", + renderSourceNodesHelpText: () => "Usage: openclaw nodes\n", + renderSourceSubcommandHelpTextRecord: () => ({ + doctor: "Usage: openclaw doctor\n", + gateway: "Usage: openclaw gateway\n", + models: "Usage: openclaw models\n", + plugins: "Usage: openclaw plugins\n", + sessions: "Usage: openclaw sessions\n", + tasks: "Usage: openclaw tasks\n", + }), + }) + .then( + () => undefined, + (reason: unknown) => reason, + ); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("primary browser failure"); + expect(error).toMatchObject({ cleanupError: cleanupFailure }); + } finally { + removeState.mockRestore(); + if (renderStateDir) { + realRmSync(renderStateDir, { force: true, recursive: true }); + } + } + }); + it("regenerates nodes help when bundled canvas CLI help sources change", async () => { const tempRoot = createTempDir("openclaw-startup-metadata-signature-"); const distDir = path.join(tempRoot, "dist");