mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
c70aee247e
* refactor(scripts): migrate JavaScript tools to TypeScript * fix(ci): keep changed-scope preflight zero-install * fix(ci): preserve zero-install script owners * fix(ci): complete script migration follow-through * fix(release): keep stable closeout zero-install * fix(scripts): preserve standalone execution boundaries * fix(scripts): repair standalone loader boundaries * fix(scripts): normalize gateway observation ids * fix(scripts): keep Docker packager standalone * test(scripts): preserve rebase cleanup helpers * test(sessions): use tracked temp directory
426 lines
15 KiB
JavaScript
426 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
// Measures CLI startup memory with an isolated home and RSS hook.
|
|
import { spawnSync as defaultSpawnSync } from "node:child_process";
|
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { resolveRepoRoot } from "./lib/repo-root.mjs";
|
|
const repoRoot = resolveRepoRoot(import.meta.url);
|
|
const tmpDir = process.env.TMPDIR || process.env.TEMP || process.env.TMP || os.tmpdir();
|
|
const MAX_RSS_MARKER = "__OPENCLAW_MAX_RSS_KB__=";
|
|
const DEFAULT_COMMAND_TIMEOUT_MS = 60_000;
|
|
const STARTUP_MEMORY_SAMPLE_COUNT = 3;
|
|
const COMMAND_TIMEOUT_MS = readPositiveIntEnv(
|
|
"OPENCLAW_STARTUP_MEMORY_TIMEOUT_MS",
|
|
DEFAULT_COMMAND_TIMEOUT_MS,
|
|
);
|
|
let tmpHome = null;
|
|
let rssHookPath = null;
|
|
const PASS = "pass";
|
|
const FAIL = "fail";
|
|
function readPositiveIntEnv(name, fallback, env = process.env) {
|
|
const value = readPositiveNumberEnv(name, fallback, env);
|
|
if (!Number.isSafeInteger(value)) {
|
|
throw new Error(`${name} must be a positive integer`);
|
|
}
|
|
return value;
|
|
}
|
|
function readPositiveNumberEnv(name, fallback, env = process.env) {
|
|
const raw = env[name];
|
|
if (raw === undefined || raw === "") {
|
|
return fallback;
|
|
}
|
|
const text = raw.trim();
|
|
if (!/^(?:\d+(?:\.\d+)?|\.\d+)$/u.test(text)) {
|
|
throw new Error(`${name} must be a positive number`);
|
|
}
|
|
const value = Number(text);
|
|
if (!Number.isFinite(value) || value <= 0) {
|
|
throw new Error(`${name} must be a positive number`);
|
|
}
|
|
return value;
|
|
}
|
|
function readNonEmptyEnv(name) {
|
|
const value = process.env[name];
|
|
return value === undefined || value.length === 0 ? null : value;
|
|
}
|
|
function readRequiredPathOption(argv, index, flag) {
|
|
const value = argv[index + 1];
|
|
if (!value || value.startsWith("-")) {
|
|
throw new Error(`${flag} requires a path`);
|
|
}
|
|
return value;
|
|
}
|
|
function parseArgs(argv) {
|
|
const options = {
|
|
jsonPath:
|
|
readNonEmptyEnv("OPENCLAW_STARTUP_MEMORY_JSON_PATH") ??
|
|
path.join(repoRoot, ".artifacts", "startup-memory", "startup-memory.json"),
|
|
summaryPath:
|
|
readNonEmptyEnv("OPENCLAW_STARTUP_MEMORY_SUMMARY_PATH") ??
|
|
path.join(repoRoot, ".artifacts", "startup-memory", "summary.md"),
|
|
};
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
if (arg === undefined) {
|
|
break;
|
|
}
|
|
if (arg === "--json") {
|
|
const value = readRequiredPathOption(argv, index, "--json");
|
|
options.jsonPath = path.resolve(value);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--summary") {
|
|
const value = readRequiredPathOption(argv, index, "--summary");
|
|
options.summaryPath = path.resolve(value);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--help") {
|
|
console.log(
|
|
"Usage: node scripts/check-cli-startup-memory.mjs [--json <path>] [--summary <path>]",
|
|
);
|
|
process.exit(0);
|
|
}
|
|
throw new Error(`Unknown option: ${arg}`);
|
|
}
|
|
return options;
|
|
}
|
|
function resolveDefaultLimitsMb(platform = process.platform) {
|
|
return {
|
|
// Linux CI is the tight startup regression signal. macOS consistently reports
|
|
// higher RSS for the same launcher path, so keep it supported without hiding
|
|
// Linux help-path regressions.
|
|
help: platform === "darwin" ? 300 : 100,
|
|
// Plugin discovery is heavier than help, but must stay below the doctor/channel
|
|
// runtime graph that an empty metadata-only invocation must not import.
|
|
pluginsList: platform === "darwin" ? 500 : 400,
|
|
// Node 24 status startup reaches ~430 MB on current Linux runner images;
|
|
// retain useful regression headroom without failing on allocator variance.
|
|
statusJson: 450,
|
|
gatewayStatus: 500,
|
|
};
|
|
}
|
|
const DEFAULT_LIMITS_MB = resolveDefaultLimitsMb();
|
|
const cases = [
|
|
{
|
|
id: "help",
|
|
label: "--help",
|
|
args: ["openclaw.mjs", "--help"],
|
|
limitMb: readPositiveNumberEnv("OPENCLAW_STARTUP_MEMORY_HELP_MB", DEFAULT_LIMITS_MB.help),
|
|
},
|
|
{
|
|
id: "pluginsList",
|
|
label: "plugins list --json",
|
|
args: ["openclaw.mjs", "plugins", "list", "--json"],
|
|
limitMb: readPositiveNumberEnv(
|
|
"OPENCLAW_STARTUP_MEMORY_PLUGINS_LIST_MB",
|
|
DEFAULT_LIMITS_MB.pluginsList,
|
|
),
|
|
},
|
|
{
|
|
id: "statusJson",
|
|
label: "status --json",
|
|
args: ["openclaw.mjs", "status", "--json"],
|
|
limitMb: readPositiveNumberEnv(
|
|
"OPENCLAW_STARTUP_MEMORY_STATUS_JSON_MB",
|
|
DEFAULT_LIMITS_MB.statusJson,
|
|
),
|
|
},
|
|
{
|
|
id: "gatewayStatus",
|
|
label: "gateway status",
|
|
args: ["openclaw.mjs", "gateway", "status"],
|
|
limitMb: readPositiveNumberEnv(
|
|
"OPENCLAW_STARTUP_MEMORY_GATEWAY_STATUS_MB",
|
|
DEFAULT_LIMITS_MB.gatewayStatus,
|
|
),
|
|
},
|
|
];
|
|
function formatFixGuidance(testCase, details) {
|
|
const command = `node ${testCase.args.join(" ")}`;
|
|
const guidance = [
|
|
"[startup-memory] Fix guidance",
|
|
`Case: ${testCase.label}`,
|
|
`Command: ${command}`,
|
|
"Next steps:",
|
|
`1. Run \`${command}\` locally on the built tree.`,
|
|
"2. If this is an RSS overage, compare the startup import graph against the last passing commit and look for newly eager imports, bootstrap side effects, or plugin loading on the command path.",
|
|
"3. If this is a non-zero exit, inspect the first transitive import/config error in stderr and fix that root cause before re-checking memory.",
|
|
"LLM prompt:",
|
|
`"OpenClaw startup-memory CI failed for '${testCase.label}'. Analyze this failure, identify the first runtime/import side effect that makes startup heavier or broken, and propose the smallest safe patch. Failure output:\n${details}"`,
|
|
];
|
|
return `${guidance.join("\n")}\n`;
|
|
}
|
|
function formatFailure(testCase, message, details = "") {
|
|
const trimmedDetails = details.trim();
|
|
const sections = [message];
|
|
if (trimmedDetails) {
|
|
sections.push(trimmedDetails);
|
|
}
|
|
sections.push(formatFixGuidance(testCase, trimmedDetails || message));
|
|
return sections.join("\n\n");
|
|
}
|
|
function failResult(report, testCase, error, details = "") {
|
|
return {
|
|
...report,
|
|
status: FAIL,
|
|
error,
|
|
failureMessage: formatFailure(testCase, error, details),
|
|
};
|
|
}
|
|
function parseMaxRssMb(stderr) {
|
|
const matches = [...stderr.matchAll(new RegExp(`^${MAX_RSS_MARKER}(\\d+)\\s*$`, "gm"))];
|
|
const lastMatch = matches.at(-1);
|
|
if (!lastMatch) {
|
|
return null;
|
|
}
|
|
const maxRssKb = Number(lastMatch[1]);
|
|
return Number.isFinite(maxRssKb) && maxRssKb > 0 ? maxRssKb / 1024 : null;
|
|
}
|
|
function formatMb(value) {
|
|
return typeof value === "number" && Number.isFinite(value) ? `${value.toFixed(1)} MB` : "n/a";
|
|
}
|
|
function formatCaseCommand(testCase) {
|
|
return `node ${testCase.args.join(" ")}`;
|
|
}
|
|
function nodeImportSpecifierForPath(filePath) {
|
|
return pathToFileURL(filePath).href;
|
|
}
|
|
function buildBenchEnv(homeDir = tmpHome) {
|
|
if (!homeDir) {
|
|
throw new Error("temporary home is not initialized");
|
|
}
|
|
const env = {
|
|
HOME: homeDir,
|
|
USERPROFILE: homeDir,
|
|
XDG_CONFIG_HOME: path.join(homeDir, ".config"),
|
|
XDG_DATA_HOME: path.join(homeDir, ".local", "share"),
|
|
XDG_CACHE_HOME: path.join(homeDir, ".cache"),
|
|
PATH: process.env.PATH ?? "",
|
|
TMPDIR: tmpDir,
|
|
TEMP: tmpDir,
|
|
TMP: tmpDir,
|
|
LANG: process.env.LANG ?? "C.UTF-8",
|
|
TERM: process.env.TERM ?? "dumb",
|
|
};
|
|
if (process.env.LC_ALL) {
|
|
env.LC_ALL = process.env.LC_ALL;
|
|
}
|
|
if (process.env.CI) {
|
|
env.CI = process.env.CI;
|
|
}
|
|
if (process.env.NODE_DISABLE_COMPILE_CACHE) {
|
|
env.NODE_DISABLE_COMPILE_CACHE = process.env.NODE_DISABLE_COMPILE_CACHE;
|
|
} else {
|
|
// Keep the regression check focused on app/runtime startup, not Node's
|
|
// one-shot compile cache overhead, which varies across runner builds.
|
|
env.NODE_DISABLE_COMPILE_CACHE = "1";
|
|
}
|
|
// Keep the benchmark on a single process so RSS reflects the actual command
|
|
// path rather than the warning-suppression respawn wrapper.
|
|
env.OPENCLAW_NO_RESPAWN = "1";
|
|
return env;
|
|
}
|
|
function runCaseSample(testCase, sampleIndex, params = {}) {
|
|
if (!rssHookPath) {
|
|
throw new Error("RSS hook path is not initialized");
|
|
}
|
|
if (!tmpHome) {
|
|
throw new Error("temporary home is not initialized");
|
|
}
|
|
const sampleHome = path.join(tmpHome, "homes", `${testCase.id}-${sampleIndex + 1}`);
|
|
mkdirSync(sampleHome, { recursive: true });
|
|
const env = buildBenchEnv(sampleHome);
|
|
const spawn = params.spawnSync ?? defaultSpawnSync;
|
|
const timeoutMs = params.timeoutMs ?? COMMAND_TIMEOUT_MS;
|
|
const result = spawn(
|
|
process.execPath,
|
|
["--import", nodeImportSpecifierForPath(rssHookPath), ...testCase.args],
|
|
{
|
|
cwd: repoRoot,
|
|
env,
|
|
encoding: "utf8",
|
|
maxBuffer: 20 * 1024 * 1024,
|
|
timeout: timeoutMs,
|
|
killSignal: "SIGKILL",
|
|
},
|
|
);
|
|
const stderr = String(result.stderr ?? "");
|
|
const stdout = String(result.stdout ?? "");
|
|
const maxRssMb = parseMaxRssMb(stderr);
|
|
const matrixBootstrapWarning = /matrix: crypto runtime bootstrap failed/i.test(stderr);
|
|
const report = {
|
|
id: testCase.id,
|
|
label: testCase.label,
|
|
command: formatCaseCommand(testCase),
|
|
limitMb: testCase.limitMb,
|
|
maxRssMb,
|
|
status: PASS,
|
|
exitCode: result.status,
|
|
signal: result.signal ?? null,
|
|
error: null,
|
|
};
|
|
if (result.error) {
|
|
const timedOut = result.error.code === "ETIMEDOUT";
|
|
const error = timedOut
|
|
? `${testCase.label} timed out after ${timeoutMs}ms`
|
|
: `${testCase.label} failed to start: ${result.error.message}`;
|
|
return failResult(report, testCase, error, stderr.trim() || stdout);
|
|
}
|
|
if (result.status !== 0) {
|
|
const exitDetail = result.status ?? result.signal ?? "unknown";
|
|
const error = `${testCase.label} exited with ${String(exitDetail)}`;
|
|
return failResult(report, testCase, error, stderr.trim() || stdout);
|
|
}
|
|
if (maxRssMb == null) {
|
|
const error = `${testCase.label} did not report max RSS`;
|
|
return failResult(report, testCase, error, stderr);
|
|
}
|
|
if (matrixBootstrapWarning) {
|
|
const error = `${testCase.label} triggered Matrix crypto bootstrap during startup`;
|
|
return failResult(report, testCase, error);
|
|
}
|
|
return report;
|
|
}
|
|
function median(values) {
|
|
const sorted = values.toSorted((left, right) => left - right);
|
|
const middle = sorted.at(Math.floor(sorted.length / 2));
|
|
if (middle === undefined) {
|
|
throw new Error("cannot compute median of an empty sample set");
|
|
}
|
|
return middle;
|
|
}
|
|
function formatRssSamples(samplesMb) {
|
|
return samplesMb.map((value) => value.toFixed(1)).join(", ");
|
|
}
|
|
function runCase(testCase, params = {}) {
|
|
let report = runCaseSample(testCase, 0, params);
|
|
if (report.status !== "pass" || report.maxRssMb == null) {
|
|
return report;
|
|
}
|
|
const samples = [report.maxRssMb];
|
|
// Shared CI runners occasionally produce a single allocator/RSS spike. Independent
|
|
// homes plus a median keep that outlier from masking regressions; two high samples fail.
|
|
for (let sampleIndex = 1; sampleIndex < STARTUP_MEMORY_SAMPLE_COUNT; sampleIndex += 1) {
|
|
const sample = runCaseSample(testCase, sampleIndex, params);
|
|
if (sample.status !== "pass" || sample.maxRssMb == null) {
|
|
return sample;
|
|
}
|
|
samples.push(sample.maxRssMb);
|
|
report = sample;
|
|
}
|
|
const maxRssMb = median(samples);
|
|
const result = { ...report, maxRssMb, rssSamplesMb: samples };
|
|
if (maxRssMb > testCase.limitMb) {
|
|
const error = `${testCase.label} median max RSS ${maxRssMb.toFixed(1)} MB exceeded ${testCase.limitMb} MB (samples: ${formatRssSamples(samples)} MB)`;
|
|
return failResult(result, testCase, error);
|
|
}
|
|
console.log(
|
|
`[startup-memory] ${testCase.label}: ${maxRssMb.toFixed(1)} MB median max RSS ` +
|
|
`(limit ${testCase.limitMb} MB; samples ${formatRssSamples(samples)} MB)`,
|
|
);
|
|
return result;
|
|
}
|
|
function writeReport(options, results) {
|
|
const failed = results.filter((result) => result.status !== "pass");
|
|
const report = {
|
|
generatedAt: new Date().toISOString(),
|
|
platform: process.platform,
|
|
repoRoot,
|
|
status: failed.length === 0 ? "pass" : "fail",
|
|
results: results.map(({ failureMessage: _failureMessage, ...result }) => result),
|
|
};
|
|
const lines = [
|
|
"# OpenClaw Startup Memory",
|
|
"",
|
|
`Generated: ${report.generatedAt}`,
|
|
"",
|
|
`Status: ${report.status}`,
|
|
"",
|
|
...results.map((result) => {
|
|
const samples = result.rssSamplesMb
|
|
? ` (samples: ${result.rssSamplesMb.map(formatMb).join(", ")})`
|
|
: "";
|
|
return `- ${result.label}: ${result.status} median max RSS ${formatMb(result.maxRssMb)} / ${formatMb(result.limitMb)}${samples}`;
|
|
}),
|
|
"",
|
|
];
|
|
if (failed.length > 0) {
|
|
lines.push(
|
|
"## Failures",
|
|
"",
|
|
...failed.map((result) => `- ${result.label}: ${result.error ?? "unknown failure"}`),
|
|
"",
|
|
);
|
|
}
|
|
mkdirSync(path.dirname(options.jsonPath), { recursive: true });
|
|
mkdirSync(path.dirname(options.summaryPath), { recursive: true });
|
|
writeFileSync(options.jsonPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
|
writeFileSync(options.summaryPath, `${lines.join("\n")}\n`, "utf8");
|
|
}
|
|
function runStartupMemoryCheck(argv = process.argv.slice(2), params = {}) {
|
|
const platform = params.platform ?? process.platform;
|
|
if (platform !== "linux" && platform !== "darwin") {
|
|
console.log(`[startup-memory] Skipping on unsupported platform: ${platform}`);
|
|
return { skipped: true, results: [] };
|
|
}
|
|
const options = parseArgs(argv);
|
|
tmpHome = mkdtempSync(path.join(os.tmpdir(), "openclaw-startup-memory-"));
|
|
rssHookPath = path.join(tmpHome, "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",
|
|
);
|
|
const results = [];
|
|
try {
|
|
for (const testCase of cases) {
|
|
results.push(runCase(testCase, params));
|
|
}
|
|
} finally {
|
|
writeReport(options, results);
|
|
if (tmpHome) {
|
|
rmSync(tmpHome, { recursive: true, force: true });
|
|
tmpHome = null;
|
|
rssHookPath = null;
|
|
}
|
|
}
|
|
const failure = results.find((result) => result.status !== "pass");
|
|
if (failure?.failureMessage) {
|
|
throw new Error(failure.failureMessage);
|
|
}
|
|
return { skipped: false, results };
|
|
}
|
|
/**
|
|
* Test-only access to pure startup memory helper functions.
|
|
*/
|
|
export const testing = {
|
|
cases,
|
|
nodeImportSpecifierForPath,
|
|
parseArgs,
|
|
readPositiveIntEnv,
|
|
readPositiveNumberEnv,
|
|
repoRoot,
|
|
resolveDefaultLimitsMb,
|
|
runCase,
|
|
runStartupMemoryCheck,
|
|
sampleCount: STARTUP_MEMORY_SAMPLE_COUNT,
|
|
};
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
try {
|
|
runStartupMemoryCheck();
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exitCode = 1;
|
|
}
|
|
}
|