import { spawnSync } from "node:child_process"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; type CommandCase = { id: string; name: string; args: string[]; presets: readonly string[]; }; type Sample = { ms: number; maxRssMb: number | null; exitCode: number | null; signal: string | null; }; type SummaryStats = { avg: number; p50: number; p95: number; min: number; max: number; }; type CaseSummary = { sampleCount: number; durationMs: SummaryStats; maxRssMb: SummaryStats | null; exitSummary: string; }; type SuiteResult = { entry: string; cases: Array<{ id: string; name: string; args: string[]; samples: Sample[]; summary: CaseSummary; }>; }; type CliOptions = { cases: CommandCase[]; entryPrimary: string; entrySecondary?: string; runs: number; warmup: number; timeoutMs: number; json: boolean; output?: string; cpuProfDir?: string; heapProfDir?: string; }; const DEFAULT_RUNS = 5; const DEFAULT_WARMUP = 1; const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_ENTRY = "openclaw.mjs"; const MAX_RSS_MARKER = "__OPENCLAW_MAX_RSS_KB__="; const COMMAND_CASES: readonly CommandCase[] = [ { id: "version", name: "--version", args: ["--version"], presets: ["startup"] }, { id: "help", name: "--help", args: ["--help"], presets: ["startup"] }, { id: "health", name: "health", args: ["health"], presets: ["startup", "real"] }, { id: "healthJson", name: "health --json", args: ["health", "--json"], presets: ["startup"] }, { 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: "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"], }, { id: "configGetGatewayPort", name: "config get gateway.port", args: ["config", "get", "gateway.port"], presets: ["real"], }, ] as const; function parseFlagValue(flag: string): string | undefined { const idx = process.argv.indexOf(flag); if (idx === -1) { return undefined; } return process.argv[idx + 1]; } 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) { if (process.argv[i] === flag && process.argv[i + 1]) { values.push(process.argv[i + 1]); } } return values; } function parsePositiveInt(raw: string | undefined, fallback: number): number { if (!raw) { return fallback; } const parsed = Number.parseInt(raw, 10); if (!Number.isFinite(parsed) || parsed < 0) { return fallback; } return parsed; } 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"]; } 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) { return options.caseIds.map((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 (sorted[mid - 1] + sorted[mid]) / 2; } return sorted[mid]; } 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 rssValues = samples .map((sample) => sample.maxRssMb) .filter((value): value is number => typeof value === "number" && Number.isFinite(value)); return { sampleCount: samples.length, durationMs: durations, 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)}MB`; } 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 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 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 runCase(params: { entry: string; commandCase: CommandCase; runs: number; warmup: number; timeoutMs: number; cpuProfDir?: string; heapProfDir?: string; rssHookPath: string; }): Sample[] { const samples: Sample[] = []; const totalRuns = params.warmup + params.runs; for (let i = 0; i < totalRuns; i += 1) { const nodeArgs = [ "--import", params.rssHookPath, ...buildCpuOrHeapFlags({ cpuProfDir: params.cpuProfDir, heapProfDir: params.heapProfDir, }), params.entry, ...params.commandCase.args, ]; const started = process.hrtime.bigint(); const proc = spawnSync(process.execPath, nodeArgs, { cwd: process.cwd(), env: { ...process.env, OPENCLAW_HIDE_BANNER: "1", }, stdio: ["ignore", "ignore", "pipe"], encoding: "utf8", timeout: params.timeoutMs, maxBuffer: 32 * 1024 * 1024, }); const ms = Number(process.hrtime.bigint() - started) / 1e6; if (i < params.warmup) { continue; } samples.push({ ms, maxRssMb: parseMaxRssMb(proc.stderr ?? ""), exitCode: proc.status, signal: proc.signal, }); } return samples; } function printSuite(result: SuiteResult): void { console.log(`Entry: ${result.entry}`); for (const commandCase of result.cases) { const { durationMs, maxRssMb, exitSummary } = commandCase.summary; const rssSummary = maxRssMb == null ? "rss=n/a" : `rss(avg=${formatMb(maxRssMb.avg)} p50=${formatMb(maxRssMb.p50)} p95=${formatMb(maxRssMb.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, )} ${rssSummary} exits=[${exitSummary}]`, ); } console.log(""); } function printDelta(primary: SuiteResult, secondary: SuiteResult): void { const primaryById = new Map(primary.cases.map((commandCase) => [commandCase.id, commandCase])); console.log("Delta (secondary - primary, avg)"); 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 durationSign = durationDelta > 0 ? "+" : ""; let line = `${commandCase.name.padEnd(24)} ${durationSign}${formatMs(durationDelta)} (${durationSign}${durationPct.toFixed(1)}%)`; if (baseline.summary.maxRssMb && commandCase.summary.maxRssMb) { const rssDelta = commandCase.summary.maxRssMb.avg - baseline.summary.maxRssMb.avg; const rssPct = baseline.summary.maxRssMb.avg > 0 ? (rssDelta / baseline.summary.maxRssMb.avg) * 100 : 0; const rssSign = rssDelta > 0 ? "+" : ""; line += ` rss ${rssSign}${formatMb(rssDelta)} (${rssSign}${rssPct.toFixed(1)}%)`; } console.log(line); } } function buildSuiteResult(params: { entry: string; options: CliOptions; rssHookPath: string; }): SuiteResult { const cases = params.options.cases.map((commandCase) => { const samples = runCase({ entry: params.entry, commandCase, runs: params.options.runs, warmup: params.options.warmup, timeoutMs: params.options.timeoutMs, cpuProfDir: params.options.cpuProfDir, heapProfDir: params.options.heapProfDir, rssHookPath: params.rssHookPath, }); return { id: commandCase.id, name: commandCase.name, args: commandCase.args, samples, summary: summarizeSamples(samples), }; }); return { entry: params.entry, cases, }; } function parseOptions(): CliOptions { const presets = parsePresets(parseFlagValue("--preset")); const cases = resolveCases({ presets, caseIds: parseRepeatableFlag("--case"), }); return { cases, entryPrimary: parseFlagValue("--entry-primary") ?? parseFlagValue("--entry") ?? DEFAULT_ENTRY, entrySecondary: parseFlagValue("--entry-secondary"), runs: parsePositiveInt(parseFlagValue("--runs"), DEFAULT_RUNS), warmup: parsePositiveInt(parseFlagValue("--warmup"), DEFAULT_WARMUP), timeoutMs: parsePositiveInt(parseFlagValue("--timeout-ms"), DEFAULT_TIMEOUT_MS), 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 --cpu-prof-dir Write V8 CPU profiles for each run --heap-prof-dir Write V8 heap profiles for each run --json Emit machine-readable JSON --help Show this text Case ids: ${COMMAND_CASES.map((commandCase) => `${commandCase.id} (${commandCase.name})`).join("\n ")} `); } async function main(): Promise { if (hasFlag("--help")) { printUsage(); return; } const options = parseOptions(); const tmpDir = mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-bench-")); const rssHookPath = buildRssHook(tmpDir); try { const primary = buildSuiteResult({ entry: options.entryPrimary, options, rssHookPath, }); const secondary = options.entrySecondary ? buildSuiteResult({ entry: options.entrySecondary, 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, }; if (options.output) { mkdirSync(path.dirname(options.output), { recursive: true }); writeFileSync(options.output, `${JSON.stringify(report, null, 2)}\n`, "utf8"); } if (options.json) { console.log(JSON.stringify(report, null, 2)); 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); } } finally { rmSync(tmpDir, { recursive: true, force: true }); } } await main();