openclaw/test/scripts/write-cli-startup-metadata.test.ts
Peter Steinberger e9f37f3c1d
fix(test): CLI startup, dead-code, boundary, oxlint, and tsgo tooling tests time out on loaded hosts while polling ready files (#162643)
Tooling tests for CLI startup benchmarking, dead-code checks, dev-tooling safety, direct-run entrypoints, additional boundary checks, the oxlint runner, tsgo core-test shards, the upgrade-survivor plugin proxy, and CLI startup metadata waited for fixture runners, compilers, and descendants by polling ready files, PID files, and the process table against 1-15 s wall-clock deadlines, which a loaded host outlasts.

Readiness and PID publication now arrive on owned channels (stdout/IPC lines written after the fact they announce, relayed through wrappers for nested children), completion is joined through retained child close promises and runner settlement, and waits raced against a command that can settle first fail fast with the old diagnostic. Foreign-descendant extinction without an owner join keeps a deadline-free check bound to the test signal, preserving the dead-versus-zombie policy. No product source, test timeout, product timeout, or assertion meaning changed.

Part of the polling audit from #162274. Proof on Blacksmith Testbox: delayed-startup fault probes fail the original bytes and pass these bytes; forced-abort probes reach cleanup; 180/180 standalone runs across the nine files; 3/3 replays of each of the six owning tooling shards; tsgo; Codex autoreview and ClawSweeper clean.
2026-10-01 04:34:15 -07:00

1937 lines
75 KiB
TypeScript

// Write Cli Startup Metadata tests cover write cli startup metadata script behavior.
import { spawn } from "node:child_process";
import { EventEmitter } from "node:events";
import fs, { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { syncBuiltinESMExports } from "node:module";
import os from "node:os";
import path from "node:path";
import { PassThrough } from "node:stream";
import { setTimeout as waitForRuntimeTick } from "node:timers/promises";
import { pathToFileURL } from "node:url";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { scriptProcessEntrypoints } from "../../scripts/script-process-runtime.test-support.js";
import { testing } from "../../scripts/write-cli-startup-metadata.ts";
import {
resolveRuntimeWorkerArgv,
resolveRuntimeWorkerUrl,
} from "../../src/infra/runtime-worker-url.js";
import {
fixtureReceiptClientSource,
openFixtureReceiptChannel,
type FixtureReceiptChannel,
} from "../helpers/fixture-receipts.js";
import { withinTest } from "../helpers/promise.js";
import { createScriptTestHarness } from "./test-helpers.js";
vi.mock("node:child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:child_process")>();
return { ...actual, spawn: vi.fn(actual.spawn) };
});
// 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 metadataUrl = resolveRuntimeWorkerUrl(scriptProcessEntrypoints.cliStartupMetadata);
const COMMAND_HELP_RENDER_CONCURRENCY = 2;
const DEFAULT_COMMAND_HELP_NAMES = [
"browser",
"secrets",
"nodes",
"config",
"doctor",
"gateway",
"models",
"plugins",
"sessions",
] as const;
function sourceSubcommandHelp() {
return {
config: "Usage: openclaw config\n",
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",
};
}
const sourceHelpRenderers = {
renderSourceBrowserHelpText: () => "Usage: openclaw browser\n",
renderSourceSecretsHelpText: () => "Usage: openclaw secrets\n",
renderSourceNodesHelpText: () => "Usage: openclaw nodes\n",
renderSourceSubcommandHelpTextRecord: sourceSubcommandHelp,
};
function writeFixtureFile(rootDir: string, relativePath: string, contents: string): void {
const filePath = path.join(rootDir, relativePath);
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, contents, "utf8");
}
function writeStartupMetadataSourceSignatureFixture(rootDir: string): void {
for (const relativePath of [
"extensions/browser/src/cli/browser-cli.ts",
"extensions/canvas/cli-metadata.ts",
"extensions/canvas/index.ts",
"extensions/canvas/src/cli.ts",
"src/cli/banner.ts",
"src/cli/gateway-cli.ts",
"src/cli/gateway-cli/register.ts",
"src/cli/gateway-cli/run-command.ts",
"src/cli/help-format.ts",
"src/cli/config-cli.ts",
"src/cli/models-cli.ts",
"src/cli/nodes-cli/register.ts",
"src/cli/program/register.maintenance.ts",
"src/cli/program/context.ts",
"src/cli/program/help.ts",
"src/cli/plugins-cli.ts",
"src/cli/secrets-cli.ts",
"packages/terminal-core/src/links.ts",
"packages/terminal-core/src/theme.ts",
"src/cli/daemon-cli/register-service-commands.ts",
"src/cli/program/register.status-health-sessions.ts",
"src/plugins/register-plugin-cli-command-groups.ts",
]) {
writeFixtureFile(rootDir, relativePath, "export {};\n");
}
}
function processIsAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code === "EPERM";
}
}
function createSpawnTextChild() {
return Object.assign(new EventEmitter(), {
kill: vi.fn((_signal?: NodeJS.Signals) => true),
stderr: new PassThrough(),
stdout: new PassThrough(),
});
}
// The renderer joins stopped process groups; only the OS reaper owns final PID
// disappearance, so this residual preserves the stronger absence assertion.
async function waitForProcessExit(pid: number, signal: AbortSignal): Promise<void> {
try {
while (processIsAlive(pid)) {
signal.throwIfAborted();
await waitForRuntimeTick(10, undefined, { signal });
}
} catch (error) {
if (signal.aborted) {
throw new Error(`process ${pid} was still alive when the test aborted`, { cause: error });
}
throw error;
}
}
let receipts: FixtureReceiptChannel;
beforeAll(async () => {
receipts = await openFixtureReceiptChannel();
});
afterAll(async () => {
await receipts.close();
});
describe("write-cli-startup-metadata", () => {
const { createTempDir } = createScriptTestHarness();
function metadataFixture(prefix: string) {
const tempRoot = createTempDir(prefix);
const distDir = path.join(tempRoot, "dist");
const extensionsDir = path.join(tempRoot, "extensions");
const outputPath = path.join(distDir, "cli-startup-metadata.json");
writeStartupMetadataSourceSignatureFixture(tempRoot);
writeFixtureFile(distDir, "root-help-fixture.js", "export function outputRootHelp() {}\n");
return { sourceRootDir: tempRoot, distDir, extensionsDir, outputPath };
}
it("renders source root help without blocking sibling child events", async () => {
const child = createSpawnTextChild();
const spawnMock = vi.mocked(spawn);
spawnMock.mockImplementationOnce(() => child as unknown as ReturnType<typeof spawn>);
let siblingEventObserved = false;
const siblingEvent = new Promise<void>((resolve) => {
setImmediate(() => {
siblingEventObserved = true;
resolve();
});
});
const render = testing.renderSourceRootHelpText();
child.stdout.write("Usage: openclaw\n");
setImmediate(() => {
child.emit("close", 0, null);
});
await siblingEvent;
expect(siblingEventObserved).toBe(true);
await expect(render).resolves.toBe("Usage: openclaw\n");
expect(spawnMock).toHaveBeenCalledOnce();
expect(spawnMock.mock.calls[0]?.[1]).toEqual([
"--import",
"tsx",
"--input-type=module",
"--eval",
expect.any(String),
]);
expect(spawnMock.mock.calls[0]?.[2]).toMatchObject({
detached: process.platform !== "win32",
stdio: ["ignore", "pipe", "pipe"],
});
});
it("finishes root help before rendering at most two command snapshots", async () => {
const actualSpawn = (
await vi.importActual<typeof import("node:child_process")>("node:child_process")
).spawn;
const spawnMock = vi.mocked(spawn);
const metadata = metadataFixture("openclaw-startup-metadata-scheduling-");
const startedCommands: string[] = [];
let activeCommands = 0;
let maxActiveCommands = 0;
let writePromise: Promise<void> | undefined;
let releaseRootHelp = () => {};
let reportRootHelpStarted = () => {};
const rootHelpStarted = new Promise<void>((resolve) => {
reportRootHelpStarted = resolve;
});
const rootHelpBlocked = new Promise<void>((resolve) => {
releaseRootHelp = resolve;
});
spawnMock.mockImplementation((_command, args) => {
const commandName = String(args[1]);
const child = createSpawnTextChild();
startedCommands.push(commandName);
activeCommands += 1;
maxActiveCommands = Math.max(maxActiveCommands, activeCommands);
setImmediate(() => {
child.stdout.write(`Usage: openclaw ${commandName}\n`);
activeCommands -= 1;
child.emit("close", 0, null);
});
return child as unknown as ReturnType<typeof spawn>;
});
try {
writePromise = testing.writeCliStartupMetadata({
...metadata,
renderBundledRootHelpText: async () => {
reportRootHelpStarted();
await rootHelpBlocked;
return "Usage: openclaw\n";
},
});
await rootHelpStarted;
await new Promise((resolve) => {
setImmediate(resolve);
});
expect(startedCommands).toEqual([]);
releaseRootHelp();
await writePromise;
expect(startedCommands).toEqual(DEFAULT_COMMAND_HELP_NAMES);
expect(maxActiveCommands).toBe(COMMAND_HELP_RENDER_CONCURRENCY);
} finally {
releaseRootHelp();
await writePromise?.catch(() => {});
spawnMock.mockImplementation(actualSpawn);
}
});
it("fails command help rendering when captured output exceeds the byte limit", async () => {
await expect(
testing.spawnText(["--eval", "process.stdout.write('x'.repeat(2048))"], {
cwd: process.cwd(),
env: process.env,
failureMessage: "render failed",
killGraceMs: 25,
maxOutputBytes: 1024,
timeoutMs: 5_000,
}),
).rejects.toThrow("render failed: output exceeded 1024 bytes");
});
it.each(["stdout", "stderr"] as const)(
"fails command help rendering when %s emits a stream error",
async (streamName) => {
const child = createSpawnTextChild();
const spawnProcess = vi.fn(() => child as unknown as ReturnType<typeof spawn>);
const streamError = new Error(`${streamName} pipe failed`);
const render = testing.spawnText(["--help"], {
cwd: process.cwd(),
env: process.env,
failureMessage: "render failed",
killGraceMs: 25,
maxOutputBytes: 1024,
spawnProcess: spawnProcess as typeof spawn,
timeoutMs: 5_000,
});
child[streamName].emit("error", streamError);
child.emit("close", null, "SIGTERM");
await expect(render).rejects.toMatchObject({
message: expect.stringContaining(
`render failed: ${streamName} read error: ${streamName} pipe failed`,
),
cause: streamError,
});
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
},
);
it("preserves an output-limit failure when shutdown also errors a stream", async () => {
const child = createSpawnTextChild();
const spawnProcess = vi.fn(() => child as unknown as ReturnType<typeof spawn>);
const render = testing.spawnText(["--help"], {
cwd: process.cwd(),
env: process.env,
failureMessage: "render failed",
killGraceMs: 25,
maxOutputBytes: 5,
spawnProcess: spawnProcess as typeof spawn,
timeoutMs: 5_000,
});
child.stdout.emit("data", "123456");
child.stdout.emit("error", new Error("pipe closed during shutdown"));
child.emit("close", null, "SIGTERM");
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 metadata = metadataFixture("openclaw-startup-metadata-batch-failure-");
const { outputPath } = metadata;
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);
});
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({
...metadata,
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 metadata = metadataFixture("openclaw-startup-metadata-undrained-tree-");
const { sourceRootDir: tempRoot, outputPath } = metadata;
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 = "";
try {
const writePromise = testing.writeCliStartupMetadata({
...metadata,
renderBundledRootHelpText: async () => "Usage: openclaw\n",
...sourceHelpRenderers,
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,
});
},
});
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 === "linux")(
"supervised exited descendant accepts stopped work without accepting live or unknown work",
async () => {
const { spawn: actualSpawn } =
await vi.importActual<typeof import("node:child_process")>("node:child_process");
const repoRoot = fs.realpathSync(process.cwd());
const workerArgs = resolveRuntimeWorkerArgv(metadataUrl).slice(0, -1);
const observerUrl = pathToFileURL(
path.join(repoRoot, "scripts/lib/managed-child-process.mts"),
).href;
const importScript = `
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
const metadataUrl = ${JSON.stringify(metadataUrl.href)};
const observerUrl = ${JSON.stringify(observerUrl)};
const { testing } = await import(metadataUrl);
const observer = await import(observerUrl);
const exports = {
spawnText: typeof testing?.spawnText,
writeCliStartupMetadata: typeof testing?.writeCliStartupMetadata,
inspectManagedProcessGroup: typeof observer.inspectManagedProcessGroup,
waitForManagedProcessGroupExit: typeof observer.waitForManagedProcessGroupExit,
};
for (const type of Object.values(exports)) assert.equal(type, "function");
const hash = (url) => crypto.createHash("sha256").update(fs.readFileSync(new URL(url))).digest("hex");
console.log("SUPERVISED_IMPORT_PREFLIGHT " + JSON.stringify({
node: process.version, cwd: fs.realpathSync("."), metadataUrl, observerUrl,
metadataSha256: hash(metadataUrl), observerSha256: hash(observerUrl),
loaderPrefix: ${JSON.stringify(workerArgs)}, exports,
}));
`;
const importRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-help-import-"));
mkdirSync(path.join(importRoot, "home"));
const preflight = actualSpawn(
process.execPath,
[...workerArgs, "--input-type=module", "--eval", importScript],
{
cwd: repoRoot,
env: {
PATH: process.env.PATH,
HOME: path.join(importRoot, "home"),
TMPDIR: importRoot,
OPENCLAW_STATE_DIR: path.join(importRoot, "state"),
OPENCLAW_CONFIG_PATH: path.join(importRoot, "state", "openclaw.json"),
LANG: "C.UTF-8",
CI: "1",
},
stdio: ["ignore", "pipe", "pipe"],
},
);
let importOutput = "";
let importError = "";
let importFailure: string | undefined;
let importJoined = false;
let importKill: ReturnType<typeof setTimeout> | undefined;
const stopImport = (reason: string) => {
if (importFailure) {
return;
}
importFailure = reason;
preflight.kill("SIGTERM");
importKill = setTimeout(() => preflight.kill("SIGKILL"), 2000);
};
const importDeadline = setTimeout(() => stopImport("import-only deadline exceeded"), 10_000);
preflight.stdout.on("data", (chunk: Buffer) => {
if (Buffer.byteLength(importOutput) + chunk.length <= 4096) {
importOutput += chunk.toString();
} else {
stopImport("import-only stdout bound exceeded");
}
});
preflight.stderr.on("data", (chunk: Buffer) => {
if (Buffer.byteLength(importError) + chunk.length <= 4096) {
importError += chunk.toString();
} else {
stopImport("import-only stderr bound exceeded");
}
});
try {
const result = await new Promise<{ code: number | null; signal: string | null }>(
(resolve) => {
preflight.once("error", (error) => {
importFailure = error.message;
});
preflight.once("close", (code, signal) => {
importJoined = true;
resolve({ code, signal });
});
},
);
expect(
importFailure,
"import-only gate precedes every process-tree fixture",
).toBeUndefined();
expect(result, importError).toEqual({ code: 0, signal: null });
expect(importOutput).toContain("SUPERVISED_IMPORT_PREFLIGHT ");
console.log(importOutput.trim());
} finally {
clearTimeout(importDeadline);
clearTimeout(importKill);
if (importJoined) {
fs.rmSync(importRoot, { recursive: true, force: true });
}
}
const actor = String.raw`
import fs from "node:fs";
import path from "node:path";
import { spawn } from "node:child_process";
const [root, role] = process.argv.slice(2);
const file = (name) => path.join(root, name);
const publish = (name, value) => {
const temporary = file(name + ".tmp");
fs.writeFileSync(temporary, JSON.stringify(value));
fs.renameSync(temporary, file(name));
};
const wait = async (name, timeout = 5000) => {
const deadline = Date.now() + timeout;
while (!fs.existsSync(file(name))) {
if (fs.existsSync(file("stop"))) process.exit(2);
if (Date.now() >= deadline) throw new Error("fixture gate deadline: " + name);
await new Promise((resolve) => setTimeout(resolve, 5));
}
if (fs.existsSync(file("stop"))) process.exit(2);
};
publish(role + ".ready", { pid: process.pid });
if (role === "leaf") {
await wait("leaf.exit", 45000);
} else {
await wait(role + ".go");
const childRole = role === "leader" ? "forker" : "leaf";
const child = spawn(process.execPath, [process.argv[1], root, childRole], {
stdio: "ignore", env: process.env,
});
if (role === "forker") {
child.unref();
await wait("forker.exit");
} else {
const joined = await new Promise((resolve, reject) => {
child.once("error", reject);
child.once("close", (code, signal) => resolve({ code, signal }));
});
publish("forker.joined", joined);
await wait("leader.exit", 45000);
process.stdout.write("Usage: openclaw nodes\n");
}
}
`;
const controller = String.raw`
import fs from "node:fs";
import path from "node:path";
import childProcess, { spawn } from "node:child_process";
import { syncBuiltinESMExports } from "node:module";
import { pathToFileURL } from "node:url";
const [root, mode, repo] = process.argv.slice(2);
let ownedLeaderPid, completedPsFault;
const originalSpawnSync = childProcess.spawnSync;
if (mode === "unknown") {
childProcess.spawnSync = function (...args) {
const result = Reflect.apply(originalSpawnSync, this, args);
const [command, argv] = args;
if (command === "ps" && ownedLeaderPid !== undefined && Array.isArray(argv) &&
argv.length === 5 && argv[0] === "-s" && argv[1] === String(ownedLeaderPid) &&
argv[2] === "-L" && argv[3] === "-o" && argv[4] === "pgid=,state=" &&
result.status === 23 && result.signal === null && !result.error) {
completedPsFault ??= { groupPid: ownedLeaderPid, status: result.status,
signal: result.signal, errorPresent: !!result.error };
}
return result;
};
syncBuiltinESMExports();
}
const { testing } = await import(${JSON.stringify(metadataUrl.href)});
const { inspectManagedProcessGroup, waitForManagedProcessGroupExit } =
await import(pathToFileURL(path.join(repo, "scripts/lib/managed-child-process.mts")));
const file = (name) => path.join(root, name);
const publish = (name, value) => {
const temporary = file(name + ".tmp");
fs.writeFileSync(temporary, JSON.stringify(value));
fs.renameSync(temporary, file(name));
};
let renderState = "", liveControl;
const events = [];
const started = Date.now();
const outputPath = file("dist/cli-startup-metadata.json");
let outcome;
try {
await testing.writeCliStartupMetadata({
sourceRootDir: root, distDir: file("dist"), extensionsDir: file("extensions"), outputPath,
renderBundledRootHelpText: () => "Usage: openclaw\n",
renderSourceRootHelpText: () => "Usage: openclaw\n",
renderSourceBrowserHelpText: () => "Usage: openclaw browser\n",
renderSourceSecretsHelpText: () => "Usage: openclaw secrets\n",
renderSourceSubcommandHelpTextRecord: () => Object.fromEntries(
["config", "doctor", "gateway", "models", "plugins", "sessions", "tasks"]
.map((name) => [name, "Usage: openclaw " + name + "\n"])),
renderSourceNodesHelpText: (context, taskContext) => {
if (!taskContext) throw new Error("missing actual supervisor task context");
renderState = context.env.OPENCLAW_STATE_DIR;
// Unknown mode fails every snapshot while the reaper holds the stopped group.
return testing.spawnText([file("actor.mjs"), root, "leader"], {
cwd: root, env: process.env, failureMessage: "supervised nodes fixture failed",
timeoutMs: 120000, killGraceMs: mode === "unknown" ? 1000 : 5000, maxOutputBytes: 16384,
onTerminalFailure: taskContext.reportFailure, signal: taskContext.signal,
spawnProcess: (...args) => {
const child = spawn(...args);
ownedLeaderPid = child.pid;
child.once("exit", (code, signal) => events.push({ event: "exit", code, signal }));
child.once("close", (code, signal) => {
events.push({ event: "close", code, signal });
publish("leader.closed", { code, signal });
});
if (mode === "live") {
liveControl = (async () => {
const readyBy = Date.now() + 5000;
while (!fs.existsSync(file("live-control.go"))) {
if (Date.now() >= readyBy) throw new Error("live control setup deadline");
await new Promise((resolve) => setTimeout(resolve, 5));
}
const start = Date.now();
const deadlineAt = start + 100;
const observation = inspectManagedProcessGroup(child, {
deadlineAt, errorPolicy: "alive-on-eperm",
});
const stopped = await waitForManagedProcessGroupExit(child, 100, {
deadlineAt, errorPolicy: "alive-on-eperm",
clampPollToDeadline: true, pollIntervalMs: 10,
});
publish("live-control.json", { observation, stopped, elapsedMs: Date.now() - start });
})();
}
return child;
},
});
},
});
outcome = { ok: true, nodesHelpText: JSON.parse(fs.readFileSync(outputPath, "utf8")).nodesHelpText };
} catch (error) {
outcome = { ok: false, code: error.code ?? null,
cleanupCode: error.processTreeCleanupFailure?.code ?? null,
preserveRenderState: error.preserveRenderState === true };
} finally {
if (mode === "unknown") {
childProcess.spawnSync = originalSpawnSync;
syncBuiltinESMExports();
}
}
await liveControl;
publish("outcome.json", { ...outcome, completedPsFault, events, elapsedMs: Date.now() - started,
outputPresent: fs.existsSync(outputPath), statePresent: !!renderState && fs.existsSync(renderState) });
`;
// The reaper owns the controller and adopted leaf. It never scans or signals unrelated PIDs.
const reaper = String.raw`
import ctypes, errno, json, os, pathlib, select, shutil, signal, subprocess, sys, time
root, mode, node, repo, worker_json = sys.argv[1:]
root = pathlib.Path(root)
started = time.monotonic()
deadline = started + 45
registered, reaped, snapshots = {}, [], {}
controller = None
leader = None
cancelled = False
captured = bytearray()
report = {"mode": mode}
def interrupt(_signum, _frame):
global cancelled
cancelled = True
signal.signal(signal.SIGTERM, interrupt)
signal.signal(signal.SIGINT, interrupt)
def identity(pid):
try:
fields = pathlib.Path("/proc", str(pid), "stat").read_text().rsplit(")", 1)[1].split()
except FileNotFoundError:
return None
return {"pid": pid, "state": fields[0], "ppid": int(fields[1]),
"pgid": int(fields[2]), "sid": int(fields[3]), "start": fields[19]}
def register(pid):
current = identity(pid)
if current is None:
raise RuntimeError("owned process disappeared before admission")
previous = registered.get(pid)
if previous is not None and previous["start"] != current["start"]:
raise RuntimeError("owned PID identity changed")
registered[pid] = previous or current
if len(registered) > 64:
raise RuntimeError("owned identity limit")
return current
def present(pid):
current = identity(pid)
if current is not None and current["start"] != registered[pid]["start"]:
raise RuntimeError("refusing recycled PID")
return current
def group_present():
if leader is None:
return False
try:
os.killpg(leader, 0)
return True
except ProcessLookupError:
return False
def read(name):
p = root / name
return json.loads(p.read_text()) if p.exists() else None
def gate(name):
(root / name).touch()
def drain():
if controller is None or controller.stdout.closed:
return
while select.select([controller.stdout], [], [], 0)[0]:
data = os.read(controller.stdout.fileno(), 4096)
if not data:
return
captured.extend(data)
if len(captured) > 4096:
raise RuntimeError("controller output limit")
def wait(predicate, seconds=5):
stop = min(deadline, time.monotonic() + seconds)
while True:
drain()
if cancelled:
raise RuntimeError("fixture interrupted")
value = predicate()
if value:
return value
if time.monotonic() >= stop:
raise RuntimeError("fixture handshake deadline")
if controller is not None and controller.poll() is not None:
raise RuntimeError("controller exited before handshake")
time.sleep(.005)
def threads(pid):
entries = list(pathlib.Path("/proc", str(pid), "task").iterdir())
if not entries or len(entries) > 256:
raise RuntimeError("thread bound")
rows = []
for entry in entries:
try:
fields = (entry / "stat").read_text().rsplit(")", 1)[1].split()
except (FileNotFoundError, ProcessLookupError):
if entry.name == str(pid):
raise
continue
rows.append({"pid": pid, "tid": int(entry.name), "state": fields[0],
"pgid": int(fields[2]), "sid": int(fields[3])})
if not any(row["tid"] == pid for row in rows):
raise RuntimeError("owned process leader missing from thread snapshot")
return sorted(rows, key=lambda r: r["tid"])
def session_rows(ps):
raw = subprocess.run([ps, "-s", str(leader), "-L", "-o", "pid=,ppid=,pgid=,sid=,lwp=,stat="],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=1, check=True)
if len(raw.stdout) > 8192 or len(raw.stderr) > 1024:
raise RuntimeError("ps output bound")
rows = []
for line in raw.stdout.decode("ascii").splitlines():
pid, ppid, pgid, sid, tid, state = line.split()
row = dict(zip(["pid", "ppid", "pgid", "sid", "tid"],
map(int, [pid, ppid, pgid, sid, tid])))
row["state"] = state
if row["pid"] not in registered or row["pgid"] != leader or row["sid"] != leader:
raise RuntimeError("unexpected session member")
present(row["pid"])
rows.append(row)
if not rows or len(rows) > 256:
raise RuntimeError("session thread bound")
return rows
def discover_owned():
pending, seen = [os.getpid()], set()
while pending:
pid = pending.pop()
if pid in seen:
continue
seen.add(pid)
try:
children = pathlib.Path("/proc", str(pid), "task", str(pid), "children").read_text()
except FileNotFoundError:
continue
for child in map(int, children.split()):
if identity(child) is not None:
register(child)
pending.append(child)
def reap():
while True:
try:
pid, status = os.waitpid(-1, os.WNOHANG)
except ChildProcessError:
return True
if pid == 0:
return False
reaped.append({"pid": pid, "status": status})
if controller is not None and pid == controller.pid:
controller.returncode = os.waitstatus_to_exitcode(status)
def signal_owned(sig):
discover_owned()
current = [item for pid in registered if (item := present(pid)) is not None]
members = [item for item in current if item["pgid"] == leader and item["sid"] == leader]
covered = set()
if leader is not None and any(item["state"] != "Z" for item in members):
os.killpg(leader, sig)
covered = {item["pid"] for item in members}
elif leader is not None and group_present() and not members:
raise RuntimeError("group ownership no longer provable")
for pid in registered:
if pid in covered:
continue
item = present(pid)
if item is not None and item["state"] != "Z":
try:
os.kill(pid, sig)
except ProcessLookupError:
pass
try:
if ctypes.CDLL(None, use_errno=True).prctl(36, 1, 0, 0, 0) != 0:
raise RuntimeError("subreaper unavailable")
ps = shutil.which("ps")
if not ps or not pathlib.Path(ps).is_absolute():
raise RuntimeError("absolute ps unavailable")
env = dict(os.environ)
if mode == "unknown":
shim = root / "bin"
shim.mkdir()
(shim / "ps").write_text("#!/bin/sh\nexit 23\n")
(shim / "ps").chmod(0o700)
env["PATH"] = str(shim) + os.pathsep + env["PATH"]
controller = subprocess.Popen([node, *json.loads(worker_json), str(root / "controller.mjs"),
str(root), mode, repo], cwd=repo, env=env,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
register(controller.pid)
leader = wait(lambda: read("leader.ready"))["pid"]
leader_identity = register(leader)
if leader_identity["pgid"] != leader or leader_identity["sid"] != leader:
raise RuntimeError("renderer did not own a detached group")
gate("leader.go")
forker = wait(lambda: read("forker.ready"))["pid"]
register(forker)
gate("forker.go")
leaf = wait(lambda: read("leaf.ready"))["pid"]
leaf_identity = register(leaf)
if leaf_identity["pgid"] != leader or leaf_identity["sid"] != leader:
raise RuntimeError("leaf escaped renderer group")
gate("forker.exit")
joined = wait(lambda: read("forker.joined"))
if joined != {"code": 0, "signal": None}:
raise RuntimeError("forker was not joined successfully")
adopted = wait(lambda: (item if (item := present(leaf)) and item["ppid"] == os.getpid() else None))
report["adopted"] = adopted
if mode == "live":
gate("live-control.go")
report["liveControl"] = wait(lambda: read("live-control.json"))
snapshots["heldLive"] = threads(leaf)
if any(row["state"] == "Z" for row in snapshots["heldLive"]):
raise RuntimeError("live control leaf already exited")
gate("leaf.exit")
wait(lambda: all(row["state"] == "Z" for row in threads(leaf)))
gate("leader.exit")
report["leaderClose"] = wait(lambda: read("leader.closed"))
snapshots["stoppedGroup"] = session_rows(ps)
if not all(row["state"].startswith("Z") for row in snapshots["stoppedGroup"]):
raise RuntimeError("positive boundary contains live threads")
report["signalZeroPresent"] = group_present()
report["outcome"] = wait(lambda: read("outcome.json"), 35)
snapshots["terminalGroup"] = session_rows(ps)
if not all(row["state"].startswith("Z") for row in snapshots["terminalGroup"]):
raise RuntimeError("terminal group contains live threads")
leaf_at_reap = present(leaf)
if (leaf_at_reap is None or leaf_at_reap["ppid"] != os.getpid() or
leaf_at_reap["pgid"] != leader or leaf_at_reap["sid"] != leader or
leaf_at_reap["state"] != "Z"):
raise RuntimeError("owned leaf identity or zombie state changed before reap")
reaped_pid, reaped_status = os.waitpid(leaf, os.WNOHANG)
if reaped_pid != leaf or reaped_status != 0:
raise RuntimeError("owned leaf did not reap with status zero")
reaped.append({"pid": reaped_pid, "status": reaped_status})
if group_present():
raise RuntimeError("renderer group remains after exact leaf reap")
report["controllerCode"] = controller.wait(timeout=max(0, min(5, deadline - time.monotonic())))
except BaseException as error:
report["fixtureError"] = type(error).__name__ + ": " + str(error).replace(str(root), "<fixture>")
finally:
cleanup_start = time.monotonic()
cleanup_deadline = cleanup_start + 15
gate("stop")
for name in ["leaf.exit", "forker.exit", "leader.exit"]:
gate(name)
try:
signal_owned(signal.SIGTERM)
killed = False
while time.monotonic() < cleanup_deadline:
discover_owned()
all_reaped = reap()
remaining = [pid for pid in registered if present(pid) is not None]
if all_reaped and not remaining and not group_present():
break
if not killed and time.monotonic() >= cleanup_start + 2:
signal_owned(signal.SIGKILL)
killed = True
time.sleep(.005)
remaining = [pid for pid in registered if present(pid) is not None]
report["cleanup"] = {"joined": not remaining and not group_present() and
(controller is None or controller.returncode is not None),
"remaining": remaining, "reaped": reaped,
"groupPresent": group_present()}
except BaseException as error:
report["cleanup"] = {"joined": False, "error": type(error).__name__ + ": " + str(error)}
if controller is not None:
drain()
controller.stdout.close()
report["identities"] = list(registered.values())
report["snapshots"] = snapshots
report["elapsedMs"] = round((time.monotonic() - started) * 1000)
if captured:
report["controllerOutput"] = captured.decode("utf8", errors="replace").replace(str(root), "<fixture>")
encoded = json.dumps(report, separators=(",", ":"))
if len(encoded.encode()) > 5000:
raise RuntimeError("structured evidence limit")
print(encoded, flush=True)
`;
type Probe = {
mode: string;
fixtureError?: string;
cleanup: {
joined: boolean;
groupPresent: boolean;
reaped: { pid: number; status: number }[];
};
adopted: { pid: number; pgid: number };
signalZeroPresent: boolean;
controllerCode: number;
leaderClose: { code: number; signal: string | null };
outcome: {
ok: boolean;
code?: string;
cleanupCode?: string;
nodesHelpText?: string;
preserveRenderState?: boolean;
outputPresent: boolean;
statePresent: boolean;
events: { event: string; code: number; signal: string | null }[];
completedPsFault?: {
groupPid: number;
status: number;
signal: string | null;
errorPresent: boolean;
};
};
liveControl?: { observation: string; stopped: boolean; elapsedMs: number };
};
const runProbe = async (mode: string): Promise<Probe> => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-supervised-descendant-"));
let settled = false;
writeStartupMetadataSourceSignatureFixture(root);
writeFixtureFile(
root,
"dist/root-help-fixture.js",
"export function outputRootHelp() {}\n",
);
writeFixtureFile(root, "actor.mjs", actor);
writeFixtureFile(root, "controller.mjs", controller);
mkdirSync(path.join(root, "home"));
const child = actualSpawn(
"python3",
["-c", reaper, root, mode, process.execPath, repoRoot, JSON.stringify(workerArgs)],
{
cwd: repoRoot,
env: {
PATH: process.env.PATH,
HOME: path.join(root, "home"),
TMPDIR: root,
OPENCLAW_STATE_DIR: path.join(root, "state"),
OPENCLAW_CONFIG_PATH: path.join(root, "state", "openclaw.json"),
LANG: "C.UTF-8",
CI: "1",
},
stdio: ["ignore", "pipe", "pipe"],
},
);
let stdout = "";
let stderr = "";
let overflow = false;
const term = setTimeout(() => child.kill("SIGTERM"), 45_000);
const kill = setTimeout(() => child.kill("SIGKILL"), 60_000);
child.stdout.on("data", (chunk: Buffer) => {
if (Buffer.byteLength(stdout) + chunk.length <= 16_384) {
stdout += chunk.toString();
} else {
overflow = true;
child.kill("SIGTERM");
}
});
child.stderr.on("data", (chunk: Buffer) => {
if (Buffer.byteLength(stderr) + chunk.length <= 4096) {
stderr += chunk.toString();
} else {
overflow = true;
child.kill("SIGTERM");
}
});
try {
const closed = await new Promise<{ code: number | null; signal: string | null }>(
(resolve, reject) => {
child.once("error", reject);
child.once("close", (code, signal) => resolve({ code, signal }));
},
);
expect(overflow, "fixture output exceeded its bound").toBe(false);
expect(closed, stderr).toEqual({ code: 0, signal: null });
const evidence = JSON.parse(stdout) as Probe;
settled = evidence.cleanup.joined;
console.log(`SUPERVISED_EXITED_DESCENDANT ${stdout.trim()}`);
expect(
evidence.fixtureError,
"fixture setup/runtime is not the intended RED",
).toBeUndefined();
expect(evidence.cleanup.joined, "subreaper must join every owned child").toBe(true);
expect(evidence.cleanup.groupPresent).toBe(false);
expect(evidence.cleanup.reaped).toContainEqual({ pid: evidence.adopted.pid, status: 0 });
expect(evidence.controllerCode).toBe(0);
expect(evidence.leaderClose).toEqual({ code: 0, signal: null });
expect(evidence.outcome.events).toEqual([
{ event: "exit", code: 0, signal: null },
{ event: "close", code: 0, signal: null },
]);
expect(evidence.signalZeroPresent).toBe(true);
return evidence;
} finally {
clearTimeout(term);
clearTimeout(kill);
// Failed settlement retains the synthetic state until the owned remote lease is stopped.
if (settled) {
fs.rmSync(root, { recursive: true, force: true });
}
}
};
const stopped = await runProbe("stopped");
const live = await runProbe("live");
expect(live.liveControl?.observation).not.toBe("dead");
expect(live.liveControl?.stopped).toBe(false);
expect(live.liveControl?.elapsedMs).toBeGreaterThanOrEqual(100);
const unknown = await runProbe("unknown");
expect(unknown.outcome).toMatchObject({
ok: false,
code: "EPROCESSGROUP_CLEANUP_FAILED",
preserveRenderState: true,
statePresent: true,
outputPresent: false,
});
expect(unknown.outcome.completedPsFault).toEqual({
groupPid: unknown.adopted.pgid,
status: 23,
signal: null,
errorPresent: false,
});
expect(stopped.outcome, JSON.stringify(stopped.outcome)).toMatchObject({
ok: true,
nodesHelpText: "Usage: openclaw nodes\n",
outputPresent: true,
statePresent: false,
});
},
185_000,
);
it.runIf(process.platform !== "win32")(
"cancels a default-batch sibling process tree after another command fails",
async ({ signal }) => {
const actualSpawn = (
await vi.importActual<typeof import("node:child_process")>("node:child_process")
).spawn;
const spawnMock = vi.mocked(spawn);
const metadata = metadataFixture("openclaw-startup-metadata-batch-tree-");
const { sourceRootDir: tempRoot, outputPath } = metadata;
const grandchildPidPath = path.join(tempRoot, "grandchild.pid");
const startedCommands: string[] = [];
const startedChildren: Array<ReturnType<typeof spawn>> = [];
let grandchildPid = 0;
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({
...metadata,
renderBundledRootHelpText: async () => "Usage: openclaw\n",
})
.then(
() => undefined,
(reason: unknown) => reason,
);
grandchildPid = Number(readFileSync(grandchildPidPath, "utf8"));
expect(Number.isInteger(grandchildPid)).toBe(true);
expect(grandchildPid).toBeGreaterThan(0);
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, signal);
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.runIf(process.platform !== "win32")(
"kills descendant processes when command help rendering times out",
async ({ signal }) => {
const tempRoot = createTempDir("openclaw-startup-metadata-timeout-");
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.on('SIGTERM', () => {});",
"setInterval(() => {}, 1000);",
].join("\n");
await expect(
testing.spawnText(["--input-type=module", "--eval", parentScript], {
cwd: tempRoot,
env: process.env,
failureMessage: "render failed",
killGraceMs: 25,
maxOutputBytes: 1024,
timeoutMs: 500,
}),
).rejects.toThrow("render failed: timed out after 500ms");
const grandchildPid = Number(readFileSync(markerPath, "utf8"));
expect(Number.isInteger(grandchildPid)).toBe(true);
expect(grandchildPid).toBeGreaterThan(0);
await waitForProcessExit(grandchildPid, signal);
},
);
it.runIf(process.platform !== "win32")(
"drains descendants when a command leader exits nonzero",
async ({ signal }) => {
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, signal);
},
);
it.runIf(process.platform !== "win32")(
"waits for all command help descendants before re-raising parent signals",
async ({ signal }) => {
const tempRoot = createTempDir("openclaw-startup-metadata-signal-");
const fastCommandPath = path.join(tempRoot, "fast-command.mjs");
const fastReadyPath = path.join(tempRoot, "fast-ready");
const commandPath = path.join(tempRoot, "command.mjs");
const runnerPath = path.join(tempRoot, "runner.mjs");
const grandchildPidPath = path.join(tempRoot, "grandchild.pid");
const 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', () => {});",
"process.send('ready');",
"setInterval(() => {}, 1000);",
].join("\n");
writeFixtureFile(
tempRoot,
"fast-command.mjs",
[
"import { writeFileSync } from 'node:fs';",
fixtureReceiptClientSource(receipts.endpoint),
"process.on('SIGTERM', () => process.exit(0));",
`writeFileSync(${JSON.stringify(fastReadyPath)}, "ready");`,
`sendReceipt(${JSON.stringify(fastReadyPath)}, "ready");`,
"setInterval(() => {}, 1000);",
].join("\n"),
);
writeFixtureFile(
tempRoot,
"command.mjs",
[
"import { spawn } from 'node:child_process';",
"import { writeFileSync } from 'node:fs';",
fixtureReceiptClientSource(receipts.endpoint),
`const grandchild = spawn(process.execPath, ["--eval", ${JSON.stringify(
grandchildScript,
)}], { stdio: ["ignore", "ignore", "ignore", "ipc"] });`,
"process.on('SIGTERM', () => process.exit(0));",
"grandchild.once('message', () => {",
" grandchild.disconnect();",
` writeFileSync(${JSON.stringify(grandchildPidPath)}, String(grandchild.pid));`,
` sendReceipt(${JSON.stringify(grandchildPidPath)}, "ready");`,
"});",
"setInterval(() => {}, 1000);",
].join("\n"),
);
writeStartupMetadataSourceSignatureFixture(tempRoot);
writeFixtureFile(distDir, "root-help-fixture.js", "export function outputRootHelp() {}\n");
writeFixtureFile(
tempRoot,
"runner.mjs",
[
`const { testing } = await import(${JSON.stringify(metadataUrl.href)});`,
"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,",
" killGraceMs: 100,",
" maxOutputBytes: 1024,",
" onTerminalFailure: taskContext.reportFailure,",
" signal: taskContext.signal,",
" timeoutMs: 30_000,",
" });",
"};",
"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: () => ({",
" config: 'Usage: openclaw config\\n',",
" 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',",
" }),",
"});",
].join("\n"),
);
const runner = spawn(
process.execPath,
[...resolveRuntimeWorkerArgv(metadataUrl).slice(0, -1), runnerPath],
{
cwd: process.cwd(),
stdio: "ignore",
},
);
const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(
(resolve, reject) => {
runner.once("error", reject);
runner.once("close", (code, exitSignal) => resolve({ code, signal: exitSignal }));
},
);
let grandchildPid = 0;
let stopRequested = false;
try {
const readReadyRecords = () => {
const pid = existsSync(grandchildPidPath)
? Number(readFileSync(grandchildPidPath, "utf8"))
: Number.NaN;
if (!Number.isInteger(pid) || pid <= 0) {
throw new Error(`timeout waiting for pid in ${grandchildPidPath}`);
}
expect(readFileSync(fastReadyPath, "utf8")).toBe("ready");
return pid;
};
// Both records precede their receipts. A runner exit can overtake socket
// delivery, so settlement checks the durable records before failing.
grandchildPid = await withinTest(
Promise.race([
Promise.all([
receipts.waitFor(grandchildPidPath, "ready"),
receipts.waitFor(fastReadyPath, "ready"),
]).then(readReadyRecords),
closed.then(readReadyRecords),
]),
signal,
);
expect(readFileSync(fastReadyPath, "utf8")).toBe("ready");
expect(grandchildPid).toBeGreaterThan(0);
expect(processIsAlive(grandchildPid)).toBe(true);
stopRequested = true;
runner.kill("SIGTERM");
await expect(withinTest(closed, signal)).resolves.toEqual({
code: null,
signal: "SIGTERM",
});
await waitForProcessExit(grandchildPid, signal);
const renderStateDir = readFileSync(renderStatePath, "utf8");
expect(existsSync(renderStateDir)).toBe(false);
} finally {
if (!stopRequested) {
runner.kill("SIGTERM");
}
await closed;
if (grandchildPid > 0 && processIsAlive(grandchildPid)) {
process.kill(grandchildPid, "SIGKILL");
}
}
},
);
it.each(["new", "existing", "symlinked parent"] as const)(
"writes complete startup metadata with %s output and source-rendered help",
async (outputKind) => {
const tempRoot = createTempDir("openclaw-startup-metadata-");
const distDir = path.join(tempRoot, "dist");
const outputDir =
outputKind === "symlinked parent" ? path.join(tempRoot, "dist-link") : distDir;
const extensionsDir = path.join(tempRoot, "extensions");
const outputPath = path.join(outputDir, "cli-startup-metadata.json");
mkdirSync(distDir, { recursive: true });
if (outputKind === "symlinked parent") {
fs.symlinkSync(distDir, outputDir, "junction");
}
if (outputKind === "existing") {
writeFileSync(outputPath, '{"rootHelpText":"old help"}\n');
fs.chmodSync(outputPath, 0o640);
}
fs.chmodSync(distDir, 0o750);
mkdirSync(path.join(extensionsDir, "matrix"), { recursive: true });
writeFileSync(
path.join(extensionsDir, "matrix", "package.json"),
JSON.stringify({
openclaw: {
channel: {
id: "matrix",
order: 120,
label: "Matrix",
},
},
}),
"utf8",
);
await testing.writeCliStartupMetadata({
distDir,
outputPath,
extensionsDir,
renderSourceRootHelpText: () => "Usage: openclaw\n",
...sourceHelpRenderers,
});
const written = JSON.parse(readFileSync(outputPath, "utf8")) as {
browserHelpText: string;
channelOptions: string[];
generatorSignature: string;
nodesHelpText: string;
rootHelpText: string;
secretsHelpText: string;
subcommandHelpText: {
config: string;
doctor: string;
gateway: string;
models: string;
plugins: string;
sessions: string;
};
};
expect(written.channelOptions).toContain("matrix");
expect(written.generatorSignature).toMatch(/^[a-f0-9]{40}$/u);
expect(written.browserHelpText).toContain("Usage:");
expect(written.browserHelpText).toContain("openclaw browser");
expect(written.secretsHelpText).toContain("Usage:");
expect(written.secretsHelpText).toContain("openclaw secrets");
expect(written.nodesHelpText).toContain("Usage:");
expect(written.nodesHelpText).toContain("openclaw nodes");
expect(written.rootHelpText).toContain("Usage:");
expect(written.rootHelpText).toContain("openclaw");
expect(written.subcommandHelpText.config).toContain("openclaw config");
expect(written.subcommandHelpText.doctor).toContain("openclaw doctor");
expect(written.subcommandHelpText.gateway).toContain("openclaw gateway");
expect(written.subcommandHelpText.models).toContain("openclaw models");
expect(written.subcommandHelpText.plugins).toContain("openclaw plugins");
expect(written.subcommandHelpText.sessions).toContain("openclaw sessions");
expect(fs.readdirSync(distDir)).toEqual(["cli-startup-metadata.json"]);
if (process.platform !== "win32") {
expect(fs.statSync(distDir).mode & 0o777).toBe(0o750);
expect(fs.statSync(outputPath).mode & 0o777).toBe(
outputKind === "existing" ? 0o640 : 0o666 & ~process.umask(),
);
}
},
);
it.each(["partial write", "rename"] as const)(
"preserves the prior startup metadata after a failed %s",
async (failurePhase) => {
const tempRoot = createTempDir("openclaw-startup-metadata-publication-");
const distDir = path.join(tempRoot, "dist");
const outputPath = path.join(distDir, "cli-startup-metadata.json");
writeStartupMetadataSourceSignatureFixture(tempRoot);
mkdirSync(distDir);
let revision = "before";
const options = {
distDir,
outputPath,
extensionsDir: path.join(tempRoot, "extensions"),
sourceRootDir: tempRoot,
renderSourceRootHelpText: () => `Usage: openclaw ${revision}\n`,
renderSourceBrowserHelpText: () => "Usage: openclaw browser\n",
renderSourceSecretsHelpText: () => "Usage: openclaw secrets\n",
renderSourceNodesHelpText: () => "Usage: openclaw nodes\n",
renderSourceSubcommandHelpTextRecord: () => ({
config: "Usage: openclaw config\n",
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",
}),
};
await testing.writeCliStartupMetadata(options);
const previous = readFileSync(outputPath, "utf8");
revision = "after";
const failure = Object.assign(new Error(`publication ${failurePhase} failed`), {
code: failurePhase === "partial write" ? "ENOSPC" : "EACCES",
});
const realWrite = fs.writeFileSync.bind(fs);
const realRename = fs.renameSync.bind(fs);
const write = vi
.spyOn(fs, "writeFileSync")
.mockImplementation((target, data, writeOptions) => {
if (
failurePhase === "partial write" &&
typeof data === "string" &&
data.includes('"generatedBy": "scripts/write-cli-startup-metadata.ts"')
) {
realWrite(target, data.slice(0, 32), writeOptions);
throw failure;
}
realWrite(target, data, writeOptions);
});
const rename = vi.spyOn(fs, "renameSync").mockImplementation((source, destination) => {
if (
failurePhase === "rename" &&
path.basename(String(destination)) === path.basename(outputPath)
) {
throw failure;
}
realRename(source, destination);
});
try {
syncBuiltinESMExports();
await expect(testing.writeCliStartupMetadata(options)).rejects.toBe(failure);
expect(readFileSync(outputPath, "utf8")).toBe(previous);
expect(fs.readdirSync(distDir)).toEqual([path.basename(outputPath)]);
} finally {
write.mockRestore();
rename.mockRestore();
syncBuiltinESMExports();
}
},
);
it("does not source-fallback a bundled root resource failure", async () => {
const metadata = metadataFixture("openclaw-startup-metadata-root-resource-failure-");
const { outputPath } = metadata;
const renderSourceRootHelpText = vi.fn(() => "Usage: source fallback\n");
const error = await testing
.writeCliStartupMetadata({
...metadata,
renderBundledRootHelpText: async () => {
throw Object.assign(new Error("bundled root timed out"), { code: "ETIMEDOUT" });
},
renderSourceRootHelpText,
...sourceHelpRenderers,
})
.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.each([
{ rendererExtension: "js", helperExtension: "mjs" },
{ rendererExtension: "mjs", helperExtension: "js" },
])(
"selects the .$rendererExtension root-help renderer beside a .$helperExtension helper",
async ({ rendererExtension, helperExtension }) => {
const tempRoot = createTempDir("openclaw-startup-metadata-bundle-selection-");
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(tempRoot, "package.json", '{"type":"module"}\n');
writeFixtureFile(
distDir,
`root-help-live-config-fixture.${helperExtension}`,
"async function loadRootHelpRenderOptionsForConfigSensitivePlugins() { return null; }\nexport { loadRootHelpRenderOptionsForConfigSensitivePlugins };\n",
);
writeFixtureFile(
distDir,
`root-help-renderer-fixture.${rendererExtension}`,
`import "./root-help-live-config-fixture.${helperExtension}";\nasync function outputRootHelp() { process.stdout.write('Usage: bundled renderer\\n'); }\nexport { outputRootHelp };\n`,
);
await testing.writeCliStartupMetadata({
distDir,
outputPath,
extensionsDir,
sourceRootDir: tempRoot,
renderSourceRootHelpText,
...sourceHelpRenderers,
});
const written = JSON.parse(readFileSync(outputPath, "utf8")) as {
rootHelpText: string;
};
expect(written.rootHelpText).toBe("Usage: bundled renderer\n");
expect(renderSourceRootHelpText).not.toHaveBeenCalled();
},
);
it("renders independent startup help snapshots concurrently", async () => {
const metadata = metadataFixture("openclaw-startup-metadata-concurrency-");
const { distDir, outputPath } = metadata;
const started: string[] = [];
const unblockers = new Map<string, () => void>();
const expectedStarted = ["browser", "secrets", "nodes", "subcommands"];
mkdirSync(distDir, { recursive: true });
const renderAfterUnblock = (label: string, output: string): (() => Promise<string>) => {
return async () => {
started.push(label);
await new Promise<void>((resolve) => {
unblockers.set(label, resolve);
});
return output;
};
};
const waitForAllStarted = async (): Promise<void> => {
const deadline = Date.now() + 1_000;
while (Date.now() < deadline) {
if (expectedStarted.every((label) => started.includes(label))) {
return;
}
await new Promise((resolve) => {
setTimeout(resolve, 5);
});
}
throw new Error(`startup help renderers did not start concurrently: ${started.join(", ")}`);
};
const writePromise = testing.writeCliStartupMetadata({
...metadata,
renderBundledRootHelpText: async () => "Usage: openclaw\n",
renderSourceBrowserHelpText: renderAfterUnblock("browser", "Usage: openclaw browser\n"),
renderSourceSecretsHelpText: renderAfterUnblock("secrets", "Usage: openclaw secrets\n"),
renderSourceNodesHelpText: renderAfterUnblock("nodes", "Usage: openclaw nodes\n"),
renderSourceSubcommandHelpTextRecord: async () => {
started.push("subcommands");
await new Promise<void>((resolve) => {
unblockers.set("subcommands", resolve);
});
return sourceSubcommandHelp();
},
});
await waitForAllStarted();
for (const label of expectedStarted) {
unblockers.get(label)?.();
}
await writePromise;
const written = JSON.parse(readFileSync(outputPath, "utf8")) as {
browserHelpText: string;
nodesHelpText: string;
secretsHelpText: string;
};
expect(written.browserHelpText).toContain("openclaw browser");
expect(written.secretsHelpText).toContain("openclaw secrets");
expect(written.nodesHelpText).toContain("openclaw nodes");
});
it.each([
{ title: "after successful rendering", failRender: false },
{ title: "when rendering fails", failRender: true },
])("removes isolated root-help state $title", async ({ failRender }) => {
const removeState = vi.spyOn(fs, "rmSync");
const metadata = metadataFixture("openclaw-startup-metadata-cleanup-");
let stateDir = "";
let statePresentDuringSiblingRender = false;
const writeMetadata = testing.writeCliStartupMetadata({
...metadata,
renderBundledRootHelpText: async () => "Usage: openclaw\n",
...sourceHelpRenderers,
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");
}
return "Usage: openclaw browser\n";
},
renderSourceSecretsHelpText: async () => {
await new Promise((resolve) => {
setImmediate(resolve);
});
statePresentDuringSiblingRender = existsSync(stateDir);
return "Usage: openclaw secrets\n";
},
});
if (failRender) {
await expect(writeMetadata).rejects.toThrow("browser help failed");
} else {
await expect(writeMetadata).resolves.toBeUndefined();
}
expect(stateDir).not.toBe("");
expect(statePresentDuringSiblingRender).toBe(true);
expect(existsSync(stateDir)).toBe(false);
expect(removeState).toHaveBeenCalledWith(stateDir, {
force: true,
recursive: true,
maxRetries: 6,
retryDelay: 25,
});
removeState.mockRestore();
});
it("does not let shared-state cleanup mask the primary render failure", async () => {
const metadata = metadataFixture("openclaw-startup-metadata-cleanup-failure-");
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);
});
try {
const error = await testing
.writeCliStartupMetadata({
...metadata,
renderBundledRootHelpText: async () => "Usage: openclaw\n",
...sourceHelpRenderers,
renderSourceBrowserHelpText: (renderContext) => {
renderStateDir = renderContext.env?.OPENCLAW_STATE_DIR ?? "";
throw new Error("primary browser failure");
},
})
.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 metadata = metadataFixture("openclaw-startup-metadata-signature-");
const { sourceRootDir: tempRoot, outputPath } = metadata;
let nodesRenderCount = 0;
const writeMetadata = async (): Promise<void> => {
await testing.writeCliStartupMetadata({
...metadata,
renderBundledRootHelpText: async () => "Usage: openclaw\n",
...sourceHelpRenderers,
renderSourceNodesHelpText: () => {
nodesRenderCount += 1;
return `Usage: openclaw nodes ${nodesRenderCount}\n`;
},
});
};
await writeMetadata();
await writeMetadata();
expect(nodesRenderCount).toBe(1);
const staleGeneratorMetadata = JSON.parse(readFileSync(outputPath, "utf8")) as Record<
string,
unknown
>;
staleGeneratorMetadata.generatorSignature = "stale-generator";
writeFileSync(outputPath, `${JSON.stringify(staleGeneratorMetadata, null, 2)}\n`, "utf8");
await writeMetadata();
expect(nodesRenderCount).toBe(2);
writeFixtureFile(
tempRoot,
"extensions/canvas/src/cli.ts",
"export const canvasCliHelp = 'canvas changed help';\n",
);
await writeMetadata();
const written = JSON.parse(readFileSync(outputPath, "utf8")) as {
nodesHelpText: string;
};
expect(nodesRenderCount).toBe(3);
expect(written.nodesHelpText).toContain("openclaw nodes 3");
});
it("regenerates help when build version or commit changes", async () => {
const metadata = metadataFixture("openclaw-startup-metadata-build-identity-");
const { distDir, outputPath } = metadata;
let renderCount = 0;
let commandRenderCount = 0;
const renderSubcommandHelp = () => {
commandRenderCount += 1;
const buildInfo = JSON.parse(readFileSync(path.join(distDir, "build-info.json"), "utf8")) as {
commit: string;
version: string;
};
const banner = `OpenClaw ${buildInfo.version} (${buildInfo.commit.slice(0, 7)})`;
return {
config: `${banner}\nUsage: openclaw config\n`,
doctor: `${banner}\nUsage: openclaw doctor\n`,
gateway: `${banner}\nUsage: openclaw gateway\n`,
models: `${banner}\nUsage: openclaw models\n`,
plugins: `${banner}\nUsage: openclaw plugins\n`,
sessions: `${banner}\nUsage: openclaw sessions\n`,
};
};
const writeMetadata = async (): Promise<void> => {
await testing.writeCliStartupMetadata({
...metadata,
renderBundledRootHelpText: async () => {
renderCount += 1;
return `Usage: openclaw ${renderCount}\n`;
},
renderSourceBrowserHelpText: () => {
commandRenderCount += 1;
return "Usage: openclaw browser\n";
},
renderSourceSecretsHelpText: () => {
commandRenderCount += 1;
return "Usage: openclaw secrets\n";
},
renderSourceNodesHelpText: () => {
commandRenderCount += 1;
return "Usage: openclaw nodes\n";
},
renderSourceSubcommandHelpTextRecord: renderSubcommandHelp,
});
};
writeFixtureFile(
distDir,
"build-info.json",
JSON.stringify({ version: "2026.7.2", commit: "a".repeat(40) }),
);
await writeMetadata();
await writeMetadata();
expect(renderCount).toBe(1);
expect(commandRenderCount).toBe(4);
expect(readFileSync(outputPath, "utf8")).toContain("OpenClaw 2026.7.2 (aaaaaaa)");
writeFixtureFile(
distDir,
"build-info.json",
JSON.stringify({ version: "2026.7.2", commit: "b".repeat(40) }),
);
await writeMetadata();
expect(renderCount).toBe(2);
expect(commandRenderCount).toBe(8);
expect(readFileSync(outputPath, "utf8")).toContain("OpenClaw 2026.7.2 (bbbbbbb)");
writeFixtureFile(
distDir,
"build-info.json",
JSON.stringify({ version: "2026.7.3", commit: "b".repeat(40) }),
);
await writeMetadata();
expect(renderCount).toBe(3);
expect(commandRenderCount).toBe(12);
const written = JSON.parse(readFileSync(outputPath, "utf8")) as {
subcommandHelpText: { models: string };
};
expect(written.subcommandHelpText.models).toContain("OpenClaw 2026.7.3 (bbbbbbb)");
});
});