// Bench Cli Startup script supports OpenClaw repository automation. import { execFileSync, spawn } from "node:child_process"; import { mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync, } from "node:fs"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { expectDefined } from "../packages/normalization-core/src/expect.js"; import { assertCompatibleCliStartupExecutionModes, assertCompatibleCliStartupMemoryMetrics, CLI_RUNTIME_MEMORY_METRIC, type CliStartupExecutionMode, cliStartupMemoryMetric, } from "./lib/cli-startup-memory-contract.mts"; import { inspectManagedProcessGroup, terminateManagedChild, waitForManagedProcessGroupExit, } from "./lib/managed-child-process.mts"; type CommandCase = { id: string; name: string; args: string[]; presets: readonly string[]; stateScope?: "case" | "sample"; expectedExitCodes?: readonly number[]; expectedNonzeroOutputIncludes?: readonly string[]; firstOutputBudgetMs?: number; exitBudgetMs?: number; }; type Sample = { ms: number; firstOutputMs: number | null; maxRssMb: number | null; memory?: SampleMemory; exitCode: number | null; signal: string | null; startedAt?: string; endedAt?: string; timedOut?: boolean; stdoutTail?: string; stderrTail?: string; }; type RssObservation = { pid: number; parentPid: number; matchesArguments: boolean; matchesInvocation: boolean; maxRssBytes: number | null; }; type SampleMemory = { runtimePid: number | null; processes: Array<{ pid: number; parentPid: number; role: "runtime" | "launcher" | "auxiliary" | "unresolved"; metricKind: "process-high-water-rss"; maxRssBytes: number | null; }>; error?: string; }; type CaseRuns = { warmupSamples: Sample[]; samples: Sample[]; }; type SummaryStats = { avg: number; p50: number; p95: number; min: number; max: number; }; type CaseSummary = { sampleCount: number; durationMs: SummaryStats; firstOutputMs: SummaryStats | null; maxRssMb: SummaryStats | null; exitSummary: string; }; type SuiteResult = { entry: string; executionMode?: CliStartupExecutionMode; memoryMetric?: string; cases: Array<{ id: string; name: string; args: string[]; expectedExitCodes?: number[]; expectedNonzeroOutputIncludes?: string[]; contract: { firstOutputBudgetMs: number | null; exitBudgetMs: number | null; } | null; warmupSamples?: Sample[]; samples: Sample[]; summary: CaseSummary; }>; }; type BenchmarkReport = { primary: SuiteResult; secondary?: SuiteResult | null; }; type CaseDelta = { id: string; name: string; durationAvgDeltaMs: number; durationAvgDeltaPct: number; maxRssAvgDeltaMb: number | null; maxRssAvgDeltaPct: number | null; }; type BenchmarkComparison = { baseline: string; candidate: string; deltas: CaseDelta[]; }; type BenchmarkComparisonResult = { baseline: SuiteResult; candidate: SuiteResult; comparison: BenchmarkComparison; }; type CliOptions = { cases: CommandCase[]; compareBaseline?: string; compareCandidate?: string; entryPrimary: string; entrySecondary?: string; runs: number; warmup: number; timeoutMs: number; runtimeRss: boolean; json: boolean; output?: string; cpuProfDir?: string; heapProfDir?: string; }; const DEFAULT_RUNS = 5; const DEFAULT_WARMUP = 1; const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_TIMEOUT_KILL_GRACE_MS = 1_000; const TIMEOUT_KILL_GRACE_MS = resolveTimeoutKillGraceMs(process.env); const DEFAULT_ENTRY = "openclaw.mjs"; const MAX_RSS_MARKER = "__OPENCLAW_MAX_RSS_KB__="; type SampleTransport = { prefix: string[]; binary: string; env: Record; cwd: string; }; function sampleTransport(): SampleTransport | undefined { const raw = process.env.OPENCLAW_BENCH_TRANSPORT_JSON; if (raw === undefined) { return undefined; } const value: unknown = JSON.parse(raw); if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error("Invalid benchmark transport"); } const prefix = "prefix" in value ? value.prefix : undefined; const binary = "binary" in value ? value.binary : undefined; const env = "env" in value ? value.env : undefined; const cwd = "cwd" in value ? value.cwd : undefined; if ( !Array.isArray(prefix) || prefix.length === 0 || !prefix.every( (part: unknown) => typeof part === "string" && part.length > 0 && !part.includes("\0"), ) || typeof prefix[0] !== "string" || !path.isAbsolute(prefix[0]) || typeof binary !== "string" || !path.isAbsolute(binary) || binary.includes("\0") || !env || typeof env !== "object" || Array.isArray(env) ) { throw new Error("Invalid benchmark transport"); } const fixedEnv: Record = {}; for (const [key, entry] of Object.entries(env)) { if (!/^[A-Z_][A-Z0-9_]*$/u.test(key) || typeof entry !== "string" || entry.includes("\0")) { throw new Error("Invalid benchmark transport environment"); } fixedEnv[key] = entry; } if (!fixedEnv.HOME || !path.isAbsolute(fixedEnv.HOME) || !fixedEnv.PATH) { throw new Error("Benchmark transport requires fixed HOME and PATH"); } const directory = cwd === undefined ? fixedEnv.HOME : cwd; if (typeof directory !== "string" || !path.isAbsolute(directory) || directory.includes("\0")) { throw new Error("Invalid benchmark transport cwd"); } return { prefix, binary, env: fixedEnv, cwd: directory }; } function transportedCommand( transport: SampleTransport, args: string[], env: Record = {}, timeoutMs?: number, ): { command: string; args: string[] } { return { command: expectDefined(transport.prefix[0], "benchmark transport command"), args: [ ...transport.prefix.slice(1), "/usr/bin/env", "-C", transport.cwd, "-i", ...Object.entries({ ...transport.env, ...env }).map(([key, value]) => `${key}=${value}`), ...(timeoutMs === undefined ? [] : ["/usr/bin/timeout", "--signal=TERM", "--kill-after=1s", `${timeoutMs / 1000}s`]), transport.binary, ...args, ], }; } function sampleFilesystem( transport: SampleTransport, operation: "create" | "prepare" | "remove", root?: string, config?: Record | null, hook?: string, ): string { // Only the SUT opens these paths. Its replies are never read as paths on the runner. const launch = transportedCommand(transport, [ "--input-type=module", "-e", `import fs from "node:fs"; import os from "node:os"; import path from "node:path"; const {operation,root,config,hook} = JSON.parse(fs.readFileSync(0,"utf8")); if (operation === "create") process.stdout.write(fs.mkdtempSync(path.join(os.tmpdir(),"openclaw-cli-bench-home-"))); else if (operation === "prepare") { fs.mkdirSync(path.join(root,".openclaw"),{recursive:true}); if (config) fs.writeFileSync(path.join(root,".openclaw/openclaw.json"),JSON.stringify(config)+"\\n"); fs.writeFileSync(path.join(root,"measure-rss.mjs"),hook); } else if (operation === "remove") fs.rmSync(root,{recursive:true,force:true}); else throw new Error("Invalid sample filesystem operation");`, ]); return execFileSync(launch.command, launch.args, { input: JSON.stringify({ operation, root, config, hook }), encoding: "utf8", timeout: 10_000, maxBuffer: 64 * 1024, env: { PATH: process.env.PATH }, }); } function createSampleRoot(transport?: SampleTransport): string { if (!transport) { return mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-bench-home-")); } const root = sampleFilesystem(transport, "create"); if (!path.isAbsolute(root) || /[\0\r\n]/u.test(root)) { throw new Error("Invalid SUT sample directory"); } return root; } function removeSampleRoot(root: string, transport?: SampleTransport): void { if (transport) { sampleFilesystem(transport, "remove", root); } else { rmSync(root, { recursive: true, force: true }); } } function resolveTimeoutKillGraceMs(env: NodeJS.ProcessEnv): number { const raw = env.VITEST ? env.OPENCLAW_TEST_CLI_STARTUP_TIMEOUT_KILL_GRACE_MS : undefined; if (!raw || !/^\d+$/u.test(raw)) { return DEFAULT_TIMEOUT_KILL_GRACE_MS; } const parsed = Number(raw); return Number.isSafeInteger(parsed) ? parsed : DEFAULT_TIMEOUT_KILL_GRACE_MS; } const VALUE_FLAGS = new Set([ "--case", "--compare-baseline", "--compare-candidate", "--cpu-prof-dir", "--entry", "--entry-primary", "--entry-secondary", "--heap-prof-dir", "--output", "--preset", "--runs", "--timeout-ms", "--warmup", ]); const BOOLEAN_FLAGS = new Set(["--help", "--json", "--runtime-rss"]); const COMMAND_CASES: readonly CommandCase[] = [ { id: "version", name: "--version", args: ["--version"], presets: ["startup", "response"], firstOutputBudgetMs: 1_000, exitBudgetMs: 2_000, }, { id: "help", name: "--help", args: ["--help"], presets: ["startup", "response"], firstOutputBudgetMs: 1_000, exitBudgetMs: 2_000, }, { id: "onboardHelp", name: "onboard --help", args: ["onboard", "--help"], presets: ["response"], firstOutputBudgetMs: 2_500, exitBudgetMs: 6_000, }, { id: "setupHelp", name: "setup --help", args: ["setup", "--help"], presets: ["response"], firstOutputBudgetMs: 2_500, exitBudgetMs: 6_000, }, { id: "configureHelp", name: "configure --help", args: ["configure", "--help"], presets: ["response"], firstOutputBudgetMs: 2_500, exitBudgetMs: 6_000, }, { id: "channelsAddHelp", name: "channels add --help", args: ["channels", "add", "--help"], presets: ["response"], firstOutputBudgetMs: 2_500, exitBudgetMs: 6_000, }, { id: "channelsParent", name: "channels", args: ["channels"], presets: ["response"], firstOutputBudgetMs: 2_500, exitBudgetMs: 6_000, }, { id: "doctorHelp", name: "doctor --help", args: ["doctor", "--help"], presets: ["response"], firstOutputBudgetMs: 2_500, exitBudgetMs: 6_000, }, { id: "modelsHelp", name: "models --help", args: ["models", "--help"], presets: ["response"], firstOutputBudgetMs: 2_500, exitBudgetMs: 6_000, }, { id: "pluginsHelp", name: "plugins --help", args: ["plugins", "--help"], presets: ["response"], firstOutputBudgetMs: 2_500, exitBudgetMs: 6_000, }, { id: "pluginsParent", name: "plugins", args: ["plugins"], presets: ["response"], firstOutputBudgetMs: 2_500, exitBudgetMs: 6_000, }, { id: "pluginsListJson", name: "plugins list --json", args: ["plugins", "list", "--json"], presets: ["response", "real"], firstOutputBudgetMs: 2_500, exitBudgetMs: 6_000, }, { id: "gatewayHelp", name: "gateway --help", args: ["gateway", "--help"], presets: ["response"], firstOutputBudgetMs: 2_500, exitBudgetMs: 6_000, }, { id: "agentsHelp", name: "agents --help", args: ["agents", "--help"], presets: ["response"], firstOutputBudgetMs: 3_500, exitBudgetMs: 8_000, }, { id: "sessionsHelp", name: "sessions --help", args: ["sessions", "--help"], presets: ["response"], firstOutputBudgetMs: 2_500, exitBudgetMs: 6_000, }, { id: "tasksHelp", name: "tasks --help", args: ["tasks", "--help"], presets: ["response"], firstOutputBudgetMs: 2_500, exitBudgetMs: 6_000, }, { id: "messageHelp", name: "message --help", args: ["message", "--help"], presets: ["response"], firstOutputBudgetMs: 2_500, exitBudgetMs: 6_000, }, { id: "pairingHelp", name: "pairing --help", args: ["pairing", "--help"], presets: ["response"], firstOutputBudgetMs: 2_500, exitBudgetMs: 6_000, }, { id: "authHelp", name: "auth --help", args: ["auth", "--help"], presets: ["response"], firstOutputBudgetMs: 2_500, exitBudgetMs: 6_000, }, { id: "configHelp", name: "config --help", args: ["config", "--help"], presets: ["response"], firstOutputBudgetMs: 2_500, exitBudgetMs: 6_000, }, { id: "secretsHelp", name: "secrets --help", args: ["secrets", "--help"], presets: ["response"], firstOutputBudgetMs: 2_500, exitBudgetMs: 6_000, }, { id: "skillsHelp", name: "skills --help", args: ["skills", "--help"], presets: ["response"], firstOutputBudgetMs: 2_500, exitBudgetMs: 6_000, }, { id: "nodesHelp", name: "nodes --help", args: ["nodes", "--help"], presets: ["response"], firstOutputBudgetMs: 3_500, exitBudgetMs: 8_000, }, { id: "directoryHelp", name: "directory --help", args: ["directory", "--help"], presets: ["response"], firstOutputBudgetMs: 2_500, exitBudgetMs: 6_000, }, { id: "sandboxHelp", name: "sandbox --help", args: ["sandbox", "--help"], presets: ["response"], firstOutputBudgetMs: 2_500, exitBudgetMs: 6_000, }, { id: "devicesParent", name: "devices", args: ["devices"], presets: ["response"], firstOutputBudgetMs: 2_500, exitBudgetMs: 6_000, }, { id: "mcpParent", name: "mcp", args: ["mcp"], presets: ["response"], firstOutputBudgetMs: 2_500, exitBudgetMs: 6_000, }, { id: "browserHelp", name: "browser --help", args: ["browser", "--help"], presets: ["response"], firstOutputBudgetMs: 1_500, exitBudgetMs: 3_000, }, { id: "webhooksHelp", name: "webhooks --help", args: ["webhooks", "--help"], presets: ["response"], firstOutputBudgetMs: 2_500, exitBudgetMs: 6_000, }, { id: "health", name: "health", args: ["health"], presets: ["startup", "real"], expectedExitCodes: [0, 1], expectedNonzeroOutputIncludes: ["Gateway target:"], }, { id: "healthJson", name: "health --json", args: ["health", "--json"], presets: ["startup"], expectedExitCodes: [0, 1], expectedNonzeroOutputIncludes: ['"ok"', '"gateway_transport_error"'], }, { id: "statusJson", name: "status --json", args: ["status", "--json"], presets: ["startup", "real"], }, { id: "status", name: "status", args: ["status"], presets: ["startup", "real"] }, { id: "sessions", name: "sessions", args: ["sessions"], presets: ["real"] }, { id: "sessionsJson", name: "sessions --json", args: ["sessions", "--json"], presets: ["real"], }, { id: "tasksJson", name: "tasks --json", args: ["tasks", "--json"], presets: ["real"], }, { id: "tasksListJson", name: "tasks list --json", args: ["tasks", "list", "--json"], presets: ["real"], }, { id: "tasksAuditJson", name: "tasks audit --json", args: ["tasks", "audit", "--json"], presets: ["real"], }, { id: "agentsListJson", name: "agents list --json", args: ["agents", "list", "--json"], presets: ["real"], }, { id: "gatewayStatus", name: "gateway status", args: ["gateway", "status"], presets: ["real"], }, { id: "gatewayStatusJson", name: "gateway status --json", args: ["gateway", "status", "--json"], presets: ["real"], }, { id: "gatewayHealthJson", name: "gateway health --json", args: ["gateway", "health", "--json"], presets: ["real"], expectedExitCodes: [0, 1], expectedNonzeroOutputIncludes: ['"ok"', '"gateway_transport_error"'], }, { id: "gatewayHealthJsonWarmState", name: "gateway health --json (warm state)", args: ["gateway", "health", "--json"], presets: [], stateScope: "case", }, { id: "gatewayHealthJsonFreshState", name: "gateway health --json (fresh state)", args: ["gateway", "health", "--json"], presets: [], stateScope: "sample", }, { id: "configGetGatewayPort", name: "config get gateway.port", args: ["config", "get", "gateway.port"], presets: ["real"], expectedExitCodes: [0, 1], expectedNonzeroOutputIncludes: ["Config path not found: gateway.port"], }, ] as const; function parseFlagValue(flag: string): string | undefined { const idx = process.argv.indexOf(flag); if (idx === -1) { return undefined; } const value = process.argv[idx + 1]; if (!value || value.startsWith("-")) { throw new Error(`${flag} requires a value`); } return value; } function hasFlag(flag: string): boolean { return process.argv.includes(flag); } function parseRepeatableFlag(flag: string): string[] { const values: string[] = []; for (let i = 0; i < process.argv.length; i += 1) { const value = process.argv[i + 1]; if (process.argv[i] === flag && value && !value.startsWith("-")) { values.push(value); } } return values; } function validateCliArgs(argv: readonly string[] = process.argv.slice(2)): void { const seenSingleValueFlags = new Set(); for (let index = 0; index < argv.length; index += 1) { const arg = expectDefined(argv[index], `CLI benchmark argument at index ${index}`); if (VALUE_FLAGS.has(arg)) { if (arg !== "--case") { if (seenSingleValueFlags.has(arg)) { throw new Error(`${arg} was provided more than once`); } seenSingleValueFlags.add(arg); } const value = argv[index + 1]; if (!value || value.startsWith("-")) { throw new Error(`${arg} requires a value`); } index += 1; continue; } if (BOOLEAN_FLAGS.has(arg)) { continue; } throw new Error(`Unknown argument: ${arg}`); } } function parsePositiveInt(raw: string | undefined, fallback: number, label = "value"): number { return parseIntegerOption(raw, fallback, label, 1); } function parseNonNegativeInt(raw: string | undefined, fallback: number, label = "value"): number { return parseIntegerOption(raw, fallback, label, 0); } // This runner is checked out from trusted main beside frozen candidates, whose // root dependencies need not include current workspace packages. function parseIntegerOption( raw: string | undefined, fallback: number, label: string, min: number, ): number { const value = raw?.trim(); if (!value) { return fallback; } if (!/^\d+$/u.test(value)) { throw new Error(`${label} must be an integer >= ${min}; got ${JSON.stringify(raw)}`); } const parsed = Number(value); if (!Number.isSafeInteger(parsed) || parsed < min) { throw new Error(`${label} must be an integer >= ${min}; got ${JSON.stringify(raw)}`); } return parsed; } function parseGatewayPortEnv(raw: string | undefined): number { const value = raw?.trim(); if (!value) { return 32123; } const bracketHostMatch = /^\[[^\]]+\]:(\d+)$/u.exec(value); if (bracketHostMatch) { return parsePositiveInt(bracketHostMatch[1], 32123, "OPENCLAW_GATEWAY_PORT"); } if (value.startsWith("[") && value.endsWith("]")) { return 32123; } const colonCount = value.split(":").length - 1; if (colonCount > 1) { return 32123; } const portRaw = colonCount === 1 ? value.split(":")[1] : value; return parsePositiveInt(portRaw, 32123, "OPENCLAW_GATEWAY_PORT"); } function parsePresets(raw: string | undefined): string[] { if (!raw) { return ["startup"]; } const values = raw .split(",") .map((value) => value.trim()) .filter(Boolean); if (values.includes("all")) { return ["startup", "real", "response"]; } return values.length > 0 ? values : ["startup"]; } function resolveCases(options: { presets: string[]; caseIds: string[] }): CommandCase[] { const byId = new Map(COMMAND_CASES.map((commandCase) => [commandCase.id, commandCase])); if (options.caseIds.length > 0) { const seenIds = new Set(); return options.caseIds.map((id) => { if (seenIds.has(id)) { throw new Error(`Duplicate --case "${id}"`); } seenIds.add(id); const commandCase = byId.get(id); if (!commandCase) { throw new Error(`Unknown --case "${id}"`); } return commandCase; }); } return COMMAND_CASES.filter((commandCase) => commandCase.presets.some((preset) => options.presets.includes(preset)), ); } function median(values: number[]): number { if (values.length === 0) { return 0; } const sorted = [...values].toSorted((a, b) => a - b); const mid = Math.floor(sorted.length / 2); if (sorted.length % 2 === 0) { return ( (expectDefined(sorted[mid - 1], "lower middle CLI benchmark sample") + expectDefined(sorted[mid], "upper middle CLI benchmark sample")) / 2 ); } return expectDefined(sorted[mid], "middle CLI benchmark sample"); } function percentile(values: number[], p: number): number { if (values.length === 0) { return 0; } const sorted = [...values].toSorted((a, b) => a - b); const index = Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length)); return sorted[index] ?? 0; } function summarizeNumbers(values: number[]): SummaryStats { const total = values.reduce((sum, value) => sum + value, 0); const avg = values.length > 0 ? total / values.length : 0; const min = values.length > 0 ? Math.min(...values) : 0; const max = values.length > 0 ? Math.max(...values) : 0; return { avg, p50: median(values), p95: percentile(values, 95), min, max, }; } function summarizeSamples(samples: Sample[]): CaseSummary { const durations = summarizeNumbers(samples.map((sample) => sample.ms)); const firstOutputValues = samples .map((sample) => sample.firstOutputMs) .filter((value): value is number => typeof value === "number" && Number.isFinite(value)); const rssValues = samples .map((sample) => sample.maxRssMb) .filter((value): value is number => typeof value === "number" && Number.isFinite(value)); return { sampleCount: samples.length, durationMs: durations, firstOutputMs: firstOutputValues.length > 0 ? summarizeNumbers(firstOutputValues) : null, maxRssMb: rssValues.length > 0 ? summarizeNumbers(rssValues) : null, exitSummary: collectExitSummary(samples), }; } function formatMs(value: number): string { return `${value.toFixed(1)}ms`; } function formatMb(value: number): string { return `${value.toFixed(1)}MiB`; } function collectExitSummary(samples: Sample[]): string { const buckets = new Map(); for (const sample of samples) { const key = sample.signal != null ? `signal:${sample.signal}` : `code:${sample.exitCode == null ? "null" : String(sample.exitCode)}`; buckets.set(key, (buckets.get(key) ?? 0) + 1); } return [...buckets.entries()].map(([key, count]) => `${key}x${count}`).join(", "); } function buildConfigFixture(commandCase: CommandCase): Record | null { const usesSharedToken = commandCase.id === "gatewayHealthJsonWarmState" || commandCase.id === "gatewayHealthJsonFreshState"; if ( commandCase.id !== "configGetGatewayPort" && commandCase.id !== "gatewayHealthJson" && !usesSharedToken && commandCase.id !== "health" && commandCase.id !== "healthJson" ) { return null; } const port = parseGatewayPortEnv(process.env.OPENCLAW_GATEWAY_PORT); return { gateway: { auth: { mode: usesSharedToken ? "token" : "none" }, bind: "loopback", mode: "local", port, }, }; } function buildRssHook(tmpDir: string): string { const rssHookPath = path.join(tmpDir, "measure-rss.mjs"); writeFileSync( rssHookPath, [ "process.on('exit', () => {", " const usage = typeof process.resourceUsage === 'function' ? process.resourceUsage() : null;", ` if (usage && typeof usage.maxRSS === 'number') console.error('${MAX_RSS_MARKER}' + String(usage.maxRSS));`, "});", "", ].join("\n"), "utf8", ); return rssHookPath; } function parseMaxRssMb(stderr: string): number | null { const matches = [...stderr.matchAll(new RegExp(`^${MAX_RSS_MARKER}(\\d+)\\s*$`, "gm"))]; const lastMatch = matches.at(-1); if (!lastMatch?.[1]) { return null; } return Number(lastMatch[1]) / 1024; } function buildRuntimeRssHook(tmpDir: string): string { const rssHookPath = path.join(tmpDir, "measure-rss.mjs"); writeFileSync( rssHookPath, `import { writeFileSync, realpathSync } from "node:fs"; import { join } from "node:path"; import { isMainThread } from "node:worker_threads"; if (isMainThread) { const { directory, entries, args } = JSON.parse(process.env.OPENCLAW_BENCH_MEMORY); let entry; try { if (process.argv[1]) entry = realpathSync(process.argv[1]); } catch (error) { if (error.code !== "ENOENT") throw error; } const matchesArguments = JSON.stringify(process.argv.slice(2)) === JSON.stringify(args); const record = { pid: process.pid, parentPid: process.ppid, matchesArguments, matchesInvocation: entries.includes(entry) && matchesArguments, maxRssBytes: null, }; const file = join(directory, String(process.pid) + ".json"); writeFileSync(file, JSON.stringify(record)); process.on("exit", () => { record.maxRssBytes = process.resourceUsage().maxRSS * 1024; writeFileSync(file, JSON.stringify(record)); }); } `, "utf8", ); return rssHookPath; } function readSampleMemory(directory: string, entryPid: number | undefined): SampleMemory { const memory: SampleMemory = { runtimePid: null, processes: [] }; try { const observations: RssObservation[] = readdirSync(directory).map((file) => { const value: unknown = JSON.parse(readFileSync(path.join(directory, file), "utf8")); if ( typeof value !== "object" || value === null || !("pid" in value) || typeof value.pid !== "number" || !Number.isSafeInteger(value.pid) || value.pid <= 0 || file !== `${value.pid}.json` || !("parentPid" in value) || typeof value.parentPid !== "number" || !Number.isSafeInteger(value.parentPid) || value.parentPid < 0 || !("matchesArguments" in value) || typeof value.matchesArguments !== "boolean" || !("matchesInvocation" in value) || typeof value.matchesInvocation !== "boolean" || !("maxRssBytes" in value) || !( value.maxRssBytes === null || (typeof value.maxRssBytes === "number" && Number.isSafeInteger(value.maxRssBytes) && value.maxRssBytes > 0) ) ) { throw new Error("invalid process RSS observation"); } return { pid: value.pid, parentPid: value.parentPid, matchesArguments: value.matchesArguments, matchesInvocation: value.matchesInvocation, maxRssBytes: value.maxRssBytes, }; }); memory.processes = observations.map((record) => ({ pid: record.pid, parentPid: record.parentPid, role: record.matchesInvocation ? "unresolved" : "auxiliary", metricKind: "process-high-water-rss", maxRssBytes: record.maxRssBytes, })); if (observations.some((record) => record.matchesArguments && !record.matchesInvocation)) { throw new Error("unrecognized CLI entry with matching command arguments"); } const invocation = observations.filter((record) => record.matchesInvocation); const entryObservation = invocation.find((record) => record.pid === entryPid); if (!entryObservation) { throw new Error("missing CLI entry process identity"); } let current: RssObservation = entryObservation; const lineage = new Set(); // Respawns preserve CLI argv and the preload. Follow the unique invocation // chain, not exit order, RSS magnitude, or platform-specific ready flags. while (true) { if (lineage.has(current.pid)) { throw new Error("cyclic CLI process identity"); } lineage.add(current.pid); if (current.maxRssBytes === null) { throw new Error(`missing process high-water RSS for CLI PID ${current.pid}`); } const parentPid = current.pid; const children = invocation.filter((record) => record.parentPid === parentPid); if (children.length > 1) { throw new Error("ambiguous CLI runtime identity: multiple matching children"); } const child = children[0]; if (!child) { break; } current = child; } if (lineage.size !== invocation.length) { throw new Error("disconnected CLI runtime identity"); } memory.runtimePid = current.pid; for (const record of memory.processes) { if (lineage.has(record.pid)) { record.role = record.pid === current.pid ? "runtime" : "launcher"; } } } catch (error) { memory.error = error instanceof Error ? error.message : String(error); } return memory; } function memoryInvocationEntries(entry: string): string[] { const resolvedEntry = realpathSync(entry); const entries = [resolvedEntry]; // The wrapper's compile-cache handoff can enter dist directly. if (path.basename(resolvedEntry) === "openclaw.mjs") { for (const name of ["entry.js", "entry.mjs"]) { try { entries.push(realpathSync(path.join(path.dirname(resolvedEntry), "dist", name))); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") { throw error; } } } } return entries; } function nodeImportSpecifierForPath(filePath: string): string { return pathToFileURL(filePath).href; } function buildCpuOrHeapFlags(options: { cpuProfDir?: string; heapProfDir?: string }): string[] { const flags: string[] = []; if (options.cpuProfDir) { flags.push("--cpu-prof", "--cpu-prof-dir", options.cpuProfDir); } if (options.heapProfDir) { flags.push("--heap-prof", "--heap-prof-dir", options.heapProfDir); } return flags; } function appendLimited(current: string, chunk: Buffer | string, maxLength: number): string { const next = current + String(chunk); return next.length > maxLength ? next.slice(next.length - maxLength) : next; } async function runSample(params: { entry: string; commandCase: CommandCase; timeoutMs: number; runtimeRss: boolean; cpuProfDir?: string; heapProfDir?: string; rssHookPath: string; runRoot?: string; }): Promise { const transport = sampleTransport(); const runRoot = params.runRoot ?? createSampleRoot(transport); const ownsRunRoot = params.runRoot == null; const stateDir = path.join(runRoot, ".openclaw"); const configPath = path.join(stateDir, "openclaw.json"); const configFixture = buildConfigFixture(params.commandCase); let rssHookPath = params.rssHookPath; if (transport) { sampleFilesystem( transport, "prepare", runRoot, configFixture, readFileSync(rssHookPath, "utf8"), ); rssHookPath = path.join(runRoot, "measure-rss.mjs"); } else if (configFixture) { mkdirSync(stateDir, { recursive: true }); writeFileSync(configPath, `${JSON.stringify(configFixture, null, 2)}\n`, "utf8"); } const nodeArgs = [ ...(transport ? [ "--import", `data:text/javascript,${encodeURIComponent(`process.chdir(${JSON.stringify(path.dirname(params.entry))});`)}`, ] : []), "--import", nodeImportSpecifierForPath(rssHookPath), ...buildCpuOrHeapFlags({ cpuProfDir: params.cpuProfDir, heapProfDir: params.heapProfDir, }), params.entry, ...params.commandCase.args, ]; const startedAt = new Date(); const started = process.hrtime.bigint(); let firstOutputMs: number | null = null; let stdout = ""; let stderr = ""; let settled = false; let timedOut = false; let forceKillAt: number | null = null; let forceKillTimer: ReturnType | null = null; const maxOutputLength = 32 * 1024 * 1024; const memoryDirectory = params.runtimeRss ? mkdtempSync(path.join(path.dirname(params.rssHookPath), "sample-")) : undefined; try { return await new Promise((resolve) => { const sampleEnv = { HOME: runRoot, USERPROFILE: runRoot, OPENCLAW_HOME: runRoot, OPENCLAW_STATE_DIR: stateDir, OPENCLAW_CONFIG_PATH: configPath, OPENCLAW_HIDE_BANNER: "1", NO_COLOR: "1", FORCE_COLOR: "0", ...(memoryDirectory ? { OPENCLAW_BENCH_MEMORY: JSON.stringify({ directory: memoryDirectory, entries: memoryInvocationEntries(params.entry), args: params.commandCase.args, }), } : {}), }; const launch = transport ? transportedCommand( transport, nodeArgs, { ...sampleEnv, ...(process.env.OPENCLAW_GATEWAY_TOKEN ? { OPENCLAW_GATEWAY_TOKEN: process.env.OPENCLAW_GATEWAY_TOKEN } : {}), ...(process.env.OPENCLAW_GATEWAY_PORT ? { OPENCLAW_GATEWAY_PORT: process.env.OPENCLAW_GATEWAY_PORT } : {}), }, params.timeoutMs, ) : { command: process.execPath, args: nodeArgs }; const proc = spawn(launch.command, launch.args, { cwd: process.cwd(), detached: process.platform !== "win32", env: transport ? { PATH: process.env.PATH } : { ...process.env, ...sampleEnv }, stdio: ["ignore", "pipe", "pipe"], }); const finish = (sample: Omit) => { if (settled) { return; } settled = true; if (forceKillTimer) { clearTimeout(forceKillTimer); forceKillTimer = null; } const ms = Number(process.hrtime.bigint() - started) / 1e6; const memory = memoryDirectory ? readSampleMemory(memoryDirectory, proc.pid) : undefined; const runtimeRss = memory?.processes.find( (record) => record.role === "runtime", )?.maxRssBytes; resolve({ ms, firstOutputMs, maxRssMb: memory ? runtimeRss == null ? null : runtimeRss / 1024 / 1024 : parseMaxRssMb(stderr), ...(memory ? { memory } : {}), startedAt: startedAt.toISOString(), endedAt: new Date().toISOString(), ...(timedOut ? { timedOut } : {}), ...sample, }); }; const markFirstOutput = () => { if (firstOutputMs == null) { firstOutputMs = Number(process.hrtime.bigint() - started) / 1e6; } }; const timeout = setTimeout(() => { timedOut = true; signalSampleProcess(proc, "SIGTERM"); forceKillAt = Date.now() + TIMEOUT_KILL_GRACE_MS; forceKillTimer = setTimeout(() => { signalSampleProcess(proc, "SIGKILL"); }, TIMEOUT_KILL_GRACE_MS).unref?.(); }, params.timeoutMs); timeout.unref?.(); proc.stdout?.on("data", (chunk) => { markFirstOutput(); stdout = appendLimited(stdout, chunk, maxOutputLength); }); proc.stderr?.on("data", (chunk) => { markFirstOutput(); stderr = appendLimited(stderr, chunk, maxOutputLength); }); proc.once("error", (error) => { clearTimeout(timeout); stderr = appendLimited( stderr, error instanceof Error ? error.message : String(error), maxOutputLength, ); finish({ exitCode: null, signal: null, stdoutTail: tailLines(stdout, 20), stderrTail: tailLines(stderr, 20), }); }); proc.once("close", (code, signal) => { clearTimeout(timeout); const complete = () => finish({ exitCode: code, signal, ...(code === 0 && signal == null ? {} : { stdoutTail: tailLines(stdout, 20), stderrTail: tailLines(stderr, 20), }), }); if (timedOut && isSampleProcessGroupAlive(proc)) { void finishAfterTimeoutCleanup({ complete, forceKillAt, proc, }); return; } complete(); }); }); } finally { if (memoryDirectory) { rmSync(memoryDirectory, { recursive: true, force: true }); } if (ownsRunRoot) { removeSampleRoot(runRoot, transport); } } } async function finishAfterTimeoutCleanup(params: { complete: () => void; forceKillAt: number | null; proc: ReturnType; }): Promise { const graceRemainingMs = params.forceKillAt === null ? TIMEOUT_KILL_GRACE_MS : Math.max(0, params.forceKillAt - Date.now()); if (graceRemainingMs > 0) { await waitForSampleProcessGroupExit(params.proc, graceRemainingMs); } if (isSampleProcessGroupAlive(params.proc)) { signalSampleProcess(params.proc, "SIGKILL"); } await waitForSampleProcessGroupExit(params.proc, TIMEOUT_KILL_GRACE_MS); params.complete(); } function signalSampleProcess(proc: ReturnType, signal: NodeJS.Signals): void { if (!proc.pid) { return; } const handleSignalError = (error: unknown) => { const code = (error as NodeJS.ErrnoException | undefined)?.code; if (code !== "ESRCH" && code !== "EPERM") { throw error; } }; terminateManagedChild(proc, signal, { onChildSignalError: handleSignalError, onProcessGroupSignalError: handleSignalError, processGroupFallback: "never", useWindowsTaskkill: false, }); } function isSampleProcessGroupAlive(proc: ReturnType): boolean { return inspectManagedProcessGroup(proc, { errorPolicy: "alive-on-eperm" }) === "live"; } function waitForSampleProcessGroupExit( proc: ReturnType, timeoutMs: number, ): Promise { return waitForManagedProcessGroupExit(proc, timeoutMs, { errorPolicy: "alive-on-eperm" }); } async function runCase(params: { entry: string; commandCase: CommandCase; runs: number; warmup: number; timeoutMs: number; runtimeRss: boolean; cpuProfDir?: string; heapProfDir?: string; rssHookPath: string; }): Promise { const warmupSamples: Sample[] = []; const samples: Sample[] = []; const totalRuns = params.warmup + params.runs; const transport = sampleTransport(); const caseRunRoot = params.commandCase.stateScope === "case" ? createSampleRoot(transport) : undefined; try { for (let i = 0; i < totalRuns; i += 1) { const sample = await runSample({ ...params, runRoot: caseRunRoot }); if (i < params.warmup) { warmupSamples.push(sample); continue; } samples.push(sample); } return { warmupSamples, samples }; } finally { if (caseRunRoot) { removeSampleRoot(caseRunRoot, transport); } } } function tailLines(value: string, maxLines: number): string { return value.split(/\r?\n/).filter(Boolean).slice(-maxLines).join("\n"); } function printSuite(result: SuiteResult): void { console.log(`Entry: ${result.entry}`); console.log(`RSS metric: ${cliStartupMemoryMetric(result)}`); for (const commandCase of result.cases) { const { durationMs, firstOutputMs, maxRssMb, exitSummary } = commandCase.summary; const rssSummary = maxRssMb == null ? "rss=n/a" : `rss(avg=${formatMb(maxRssMb.avg)} p50=${formatMb(maxRssMb.p50)} p95=${formatMb(maxRssMb.p95)})`; const firstOutputSummary = firstOutputMs == null ? "first-output=n/a" : `first-output(avg=${formatMs(firstOutputMs.avg)} p50=${formatMs( firstOutputMs.p50, )} p95=${formatMs(firstOutputMs.p95)})`; console.log( `${commandCase.name.padEnd(24)} avg=${formatMs(durationMs.avg)} p50=${formatMs( durationMs.p50, )} p95=${formatMs(durationMs.p95)} min=${formatMs(durationMs.min)} max=${formatMs( durationMs.max, )} ${firstOutputSummary} ${rssSummary} exits=[${exitSummary}]`, ); } console.log(""); } function printDelta(primary: SuiteResult, secondary: SuiteResult): void { const deltas = buildCaseDeltas(primary, secondary); console.log("Delta (secondary - primary, avg)"); for (const delta of deltas) { const durationDelta = delta.durationAvgDeltaMs; const durationPct = delta.durationAvgDeltaPct; const durationSign = durationDelta > 0 ? "+" : ""; let line = `${delta.name.padEnd(24)} ${durationSign}${formatMs(durationDelta)} (${durationSign}${durationPct.toFixed(1)}%)`; if (delta.maxRssAvgDeltaMb != null && delta.maxRssAvgDeltaPct != null) { const rssDelta = delta.maxRssAvgDeltaMb; const rssPct = delta.maxRssAvgDeltaPct; const rssSign = rssDelta > 0 ? "+" : ""; line += ` rss ${rssSign}${formatMb(rssDelta)} (${rssSign}${rssPct.toFixed(1)}%)`; } console.log(line); } } function buildCaseDeltas(primary: SuiteResult, secondary: SuiteResult): CaseDelta[] { assertCompatibleCliStartupExecutionModes(primary, secondary); assertCompatibleCliStartupMemoryMetrics(primary, secondary); const primaryById = new Map(primary.cases.map((commandCase) => [commandCase.id, commandCase])); const deltas: CaseDelta[] = []; for (const commandCase of secondary.cases) { const baseline = primaryById.get(commandCase.id); if (!baseline) { continue; } const durationDelta = commandCase.summary.durationMs.avg - baseline.summary.durationMs.avg; const durationPct = baseline.summary.durationMs.avg > 0 ? (durationDelta / baseline.summary.durationMs.avg) * 100 : 0; const rssDelta = baseline.summary.maxRssMb && commandCase.summary.maxRssMb ? commandCase.summary.maxRssMb.avg - baseline.summary.maxRssMb.avg : null; const rssPct = rssDelta != null && baseline.summary.maxRssMb && baseline.summary.maxRssMb.avg > 0 ? (rssDelta / baseline.summary.maxRssMb.avg) * 100 : null; deltas.push({ id: commandCase.id, name: commandCase.name, durationAvgDeltaMs: durationDelta, durationAvgDeltaPct: durationPct, maxRssAvgDeltaMb: rssDelta, maxRssAvgDeltaPct: rssPct, }); } return deltas; } export function collectFailedSamples(result: SuiteResult): string[] { const failures: string[] = []; for (const commandCase of result.cases) { if (commandCase.samples.length === 0) { failures.push(`${result.entry} ${commandCase.id}: no measured samples`); } for (const [sampleKind, samples] of [ ["warmup", commandCase.warmupSamples ?? []], ["sample", commandCase.samples], ] as const) { for (const [sampleIndex, sample] of samples.entries()) { const label = `${result.entry} ${commandCase.id} ${sampleKind} ${sampleIndex + 1}`; const expectedExitCodes = new Set(commandCase.expectedExitCodes ?? [0]); if (sample.timedOut === true) { failures.push(`${label}: timed out`); } else if (sample.signal !== null) { failures.push(`${label}: exited via signal ${sample.signal}`); } else if (!expectedExitCodes.has(sample.exitCode ?? -1)) { failures.push(`${label}: exited with code ${String(sample.exitCode)}`); } else if (sample.maxRssMb === null) { failures.push( `${label}: did not report max RSS${sample.memory?.error ? ` (${sample.memory.error})` : ""}`, ); } else if (sample.exitCode !== 0) { const output = `${sample.stdoutTail ?? ""}\n${sample.stderrTail ?? ""}`; const missing = (commandCase.expectedNonzeroOutputIncludes ?? []).filter( (snippet) => !output.includes(snippet), ); if (missing.length > 0) { failures.push( `${label}: exited with expected code ${String( sample.exitCode, )} but output did not match expected clean-state markers (${missing.join(", ")})`, ); } } } } } return failures; } async function buildSuiteResult(params: { entry: string; executionMode: CliStartupExecutionMode; options: CliOptions; rssHookPath: string; }): Promise { const cases = []; for (const commandCase of params.options.cases) { const { warmupSamples, samples } = await runCase({ entry: params.entry, commandCase, runs: params.options.runs, warmup: params.options.warmup, timeoutMs: params.options.timeoutMs, runtimeRss: params.options.runtimeRss, cpuProfDir: params.options.cpuProfDir, heapProfDir: params.options.heapProfDir, rssHookPath: params.rssHookPath, }); cases.push({ id: commandCase.id, name: commandCase.name, args: commandCase.args, ...(commandCase.expectedExitCodes && commandCase.expectedExitCodes.some((code) => code !== 0) ? { expectedExitCodes: [...commandCase.expectedExitCodes] } : {}), ...(commandCase.expectedNonzeroOutputIncludes ? { expectedNonzeroOutputIncludes: [...commandCase.expectedNonzeroOutputIncludes] } : {}), contract: commandCase.firstOutputBudgetMs != null || commandCase.exitBudgetMs != null ? { firstOutputBudgetMs: commandCase.firstOutputBudgetMs ?? null, exitBudgetMs: commandCase.exitBudgetMs ?? null, } : null, warmupSamples, samples, summary: summarizeSamples(samples), }); } return { entry: params.entry, executionMode: params.executionMode, ...(params.options.runtimeRss ? { memoryMetric: CLI_RUNTIME_MEMORY_METRIC } : {}), cases, }; } function parseOptions(): CliOptions { const presets = parsePresets(parseFlagValue("--preset")); const cases = resolveCases({ presets, caseIds: parseRepeatableFlag("--case"), }); return { cases, compareBaseline: parseFlagValue("--compare-baseline"), compareCandidate: parseFlagValue("--compare-candidate"), entryPrimary: parseFlagValue("--entry-primary") ?? parseFlagValue("--entry") ?? DEFAULT_ENTRY, entrySecondary: parseFlagValue("--entry-secondary"), runs: parsePositiveInt(parseFlagValue("--runs"), DEFAULT_RUNS, "--runs"), warmup: parseNonNegativeInt(parseFlagValue("--warmup"), DEFAULT_WARMUP, "--warmup"), timeoutMs: parsePositiveInt(parseFlagValue("--timeout-ms"), DEFAULT_TIMEOUT_MS, "--timeout-ms"), runtimeRss: hasFlag("--runtime-rss"), json: hasFlag("--json"), output: parseFlagValue("--output"), cpuProfDir: parseFlagValue("--cpu-prof-dir"), heapProfDir: parseFlagValue("--heap-prof-dir"), }; } function printUsage(): void { console.log(`OpenClaw CLI benchmark Usage: pnpm tsx scripts/bench-cli-startup.ts [options] Options: --preset Command preset to run (default: startup) --case Specific case id to run; repeatable --entry Primary entry file (default: openclaw.mjs) --entry-secondary Secondary entry file for avg delta comparison --runs Measured runs per case (default: ${DEFAULT_RUNS}) --warmup Warmup runs per case (default: ${DEFAULT_WARMUP}) --timeout-ms Per-run timeout (default: ${DEFAULT_TIMEOUT_MS}) --output Write machine-readable JSON to a file --compare-baseline Read a saved JSON report as the baseline --compare-candidate Read a saved JSON report as the candidate and print deltas --cpu-prof-dir Write V8 CPU profiles for each run --heap-prof-dir Write V8 heap profiles for each run --runtime-rss Attribute RSS to the CLI runtime (default: legacy last marker) --json Emit machine-readable JSON --help Show this text Case ids: ${COMMAND_CASES.map((commandCase) => `${commandCase.id} (${commandCase.name})`).join("\n ")} `); } function readBenchmarkReport(filePath: string): BenchmarkReport { return JSON.parse(readFileSync(filePath, "utf8")) as BenchmarkReport; } function writeJsonOutput(filePath: string, value: unknown): void { mkdirSync(path.dirname(filePath), { recursive: true }); writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } function readBenchmarkComparison( baselinePath: string, candidatePath: string, ): BenchmarkComparisonResult { const baseline = readBenchmarkReport(baselinePath); const candidate = readBenchmarkReport(candidatePath); return { baseline: baseline.primary, candidate: candidate.primary, comparison: { baseline: baselinePath, candidate: candidatePath, deltas: buildCaseDeltas(baseline.primary, candidate.primary), }, }; } async function main(): Promise { validateCliArgs(); if (hasFlag("--help")) { printUsage(); return; } const options = parseOptions(); const transport = sampleTransport(); if (transport && options.runtimeRss) { throw new Error("Cross-user runtime RSS sampling is not supported"); } if (transport && (options.cpuProfDir || options.heapProfDir)) { throw new Error( "Cross-user CLI profiles must be collected through the SUT diagnostic exporter", ); } if (options.compareBaseline || options.compareCandidate) { if (!options.compareBaseline || !options.compareCandidate) { throw new Error("--compare-baseline and --compare-candidate must be provided together"); } const { baseline, candidate, comparison } = readBenchmarkComparison( options.compareBaseline, options.compareCandidate, ); if (options.output) { writeJsonOutput(options.output, comparison); } if (options.json) { console.log(JSON.stringify(comparison, null, 2)); return; } printDelta(baseline, candidate); return; } const tmpDir = mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-bench-")); const rssHookPath = options.runtimeRss ? buildRuntimeRssHook(tmpDir) : buildRssHook(tmpDir); try { const primary = await buildSuiteResult({ entry: options.entryPrimary, executionMode: transport ? "transport" : "native", options, rssHookPath, }); const secondary = options.entrySecondary ? await buildSuiteResult({ entry: options.entrySecondary, executionMode: transport ? "transport" : "native", options, rssHookPath, }) : undefined; const report = { node: process.version, runs: options.runs, warmup: options.warmup, timeoutMs: options.timeoutMs, cpuProfDir: options.cpuProfDir ?? null, heapProfDir: options.heapProfDir ?? null, primary, secondary: secondary ?? null, }; const failures = [ ...collectFailedSamples(primary), ...(secondary ? collectFailedSamples(secondary) : []), ]; if (options.output) { writeJsonOutput(options.output, report); } if (options.json) { console.log(JSON.stringify(report, null, 2)); if (failures.length > 0) { process.exitCode = 1; for (const failure of failures) { console.error(`[startup-bench] ${failure}`); } } return; } console.log(`Node: ${process.version}`); console.log(`Runs per case: ${options.runs}`); console.log(`Warmup runs per case: ${options.warmup}`); console.log(`Timeout: ${options.timeoutMs}ms`); if (options.cpuProfDir) { console.log(`CPU profiles: ${options.cpuProfDir}`); } if (options.heapProfDir) { console.log(`Heap profiles: ${options.heapProfDir}`); } console.log(""); console.log("Primary entry"); printSuite(primary); if (secondary) { console.log("Secondary entry"); printSuite(secondary); printDelta(primary, secondary); } if (failures.length > 0) { process.exitCode = 1; console.error("\nFailed startup benchmark samples:"); for (const failure of failures) { console.error(`- ${failure}`); } } } finally { rmSync(tmpDir, { recursive: true, force: true }); } } export const testing = { buildConfigFixture, collectFailedSamples, nodeImportSpecifierForPath, parseGatewayPortEnv, parseNonNegativeInt, parsePositiveInt, validateCliArgs, }; if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { await main().catch((error: unknown) => { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); }); }