mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(build): cancel stalled CLI metadata renderers (#122634)
* fix(build): drain startup metadata renderers * fix(build): preserve undrained metadata state
This commit is contained in:
committed by
GitHub
parent
df707a9670
commit
4fcd9e12d9
@@ -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<T = string> = (
|
||||
renderContext: RootHelpRenderContext,
|
||||
taskContext?: RenderTaskContext,
|
||||
) => Awaitable<T>;
|
||||
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<SpawnTextParentSignalState>();
|
||||
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<T>(render: (context: RenderTaskContext) => Awaitable<T>): Promise<T> {
|
||||
// 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<T>(
|
||||
async function settleRootHelpRenderPromises<T extends readonly unknown[]>(
|
||||
values: T,
|
||||
stateDir: string,
|
||||
supervisor: CliStartupMetadataRenderSupervisor,
|
||||
): Promise<{ -readonly [P in keyof T]: Awaited<T[P]> }> {
|
||||
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<unknown>).value) as {
|
||||
-readonly [P in keyof T]: Awaited<T[P]>;
|
||||
};
|
||||
}
|
||||
|
||||
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<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) {
|
||||
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<typeof createSpawnTextFailure>[0]["kind"],
|
||||
detail: string,
|
||||
cause?: unknown,
|
||||
) =>
|
||||
createSpawnTextFailure({
|
||||
cause,
|
||||
detail,
|
||||
failureMessage: options.failureMessage,
|
||||
kind,
|
||||
startedAt,
|
||||
});
|
||||
const fail = (
|
||||
kind: Parameters<typeof createSpawnTextFailure>[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<string> {
|
||||
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<string> {
|
||||
async function renderSourceRootHelpText(
|
||||
renderContext?: RootHelpRenderContext,
|
||||
taskContext?: RenderTaskContext,
|
||||
): Promise<string> {
|
||||
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<string> {
|
||||
async function renderSourceBrowserHelpText(
|
||||
renderContext: RootHelpRenderContext,
|
||||
taskContext?: RenderTaskContext,
|
||||
): Promise<string> {
|
||||
// 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<string> {
|
||||
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<string> {
|
||||
return await renderSourceCommandHelpText("secrets", renderContext);
|
||||
async function renderSourceSecretsHelpText(
|
||||
renderContext: RootHelpRenderContext,
|
||||
taskContext?: RenderTaskContext,
|
||||
): Promise<string> {
|
||||
return await renderSourceCommandHelpText("secrets", renderContext, taskContext);
|
||||
}
|
||||
|
||||
async function renderSourceNodesHelpText(renderContext: RootHelpRenderContext): Promise<string> {
|
||||
return await renderSourceCommandHelpText("nodes", renderContext);
|
||||
async function renderSourceNodesHelpText(
|
||||
renderContext: RootHelpRenderContext,
|
||||
taskContext?: RenderTaskContext,
|
||||
): Promise<string> {
|
||||
return await renderSourceCommandHelpText("nodes", renderContext, taskContext);
|
||||
}
|
||||
|
||||
async function renderSourceCommandHelpTextRecord(
|
||||
commands: readonly SourceCommandHelpCommand[],
|
||||
renderContext: RootHelpRenderContext,
|
||||
supervisor: CliStartupMetadataRenderSupervisor,
|
||||
): Promise<SourceCommandHelpText> {
|
||||
const helpTexts = await pMap(
|
||||
const helpTexts: Partial<Record<SourceCommandHelpCommand, string>> = {};
|
||||
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<PrecomputedSubcommandHelpText> {
|
||||
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<string>;
|
||||
renderSourceBrowserHelpText?: (renderContext: RootHelpRenderContext) => Awaitable<string>;
|
||||
renderSourceSecretsHelpText?: (renderContext: RootHelpRenderContext) => Awaitable<string>;
|
||||
renderSourceNodesHelpText?: (renderContext: RootHelpRenderContext) => Awaitable<string>;
|
||||
renderSourceSubcommandHelpTextRecord?: (
|
||||
renderContext: RootHelpRenderContext,
|
||||
) => Awaitable<PrecomputedSubcommandHelpText>;
|
||||
renderSourceRootHelpText?: SourceHelpRenderer;
|
||||
renderSourceBrowserHelpText?: SourceHelpRenderer;
|
||||
renderSourceSecretsHelpText?: SourceHelpRenderer;
|
||||
renderSourceNodesHelpText?: SourceHelpRenderer;
|
||||
renderSourceSubcommandHelpTextRecord?: SourceHelpRenderer<PrecomputedSubcommandHelpText>;
|
||||
}): Promise<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof import("node:child_process")>("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<ReturnType<typeof createSpawnTextChild> & { 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<typeof spawn>;
|
||||
});
|
||||
|
||||
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<typeof spawn>) 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<typeof import("node:child_process")>("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<ReturnType<typeof spawn>> = [];
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user