fix(cli): bound startup memory probes

This commit is contained in:
Vincent Koc
2026-05-27 02:58:45 +02:00
parent 049d6c9683
commit 2b5fba1519
3 changed files with 127 additions and 37 deletions
+1
View File
@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
- macOS: resolve Parallels npm-update smoke commands from the guest `PATH` so Intel Homebrew and other native mac layouts are not forced through `/opt/homebrew`.
- Gateway: keep dev smoke scripts on the current protocol version and make the kitchen-sink RPC walk fail on dropped diagnostics or aggregate Gateway RSS spikes.
- Gateway: make the CPU scenario checker fail when completed Gateway runs report hot CPU observations instead of only writing them to artifacts.
- CLI: bound startup-memory probes so a hung startup command fails with timeout guidance instead of hanging the memory gate indefinitely.
## 2026.5.26
+82 -37
View File
@@ -1,24 +1,27 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
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";
const isLinux = process.platform === "linux";
const isMac = process.platform === "darwin";
if (!isLinux && !isMac) {
console.log(`[startup-memory] Skipping on unsupported platform: ${process.platform}`);
process.exit(0);
}
import { pathToFileURL } from "node:url";
const repoRoot = process.cwd();
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 COMMAND_TIMEOUT_MS = readPositiveIntEnv(
"OPENCLAW_STARTUP_MEMORY_TIMEOUT_MS",
DEFAULT_COMMAND_TIMEOUT_MS,
);
let tmpHome = null;
let rssHookPath = null;
function readPositiveIntEnv(name, fallback) {
const value = Number(process.env[name] ?? "");
return Number.isInteger(value) && value > 0 ? value : fallback;
}
function parseArgs(argv) {
const options = {
jsonPath:
@@ -171,16 +174,20 @@ function buildBenchEnv() {
return env;
}
function runCase(testCase) {
function runCase(testCase, params = {}) {
if (!rssHookPath) {
throw new Error("RSS hook path is not initialized");
}
const env = buildBenchEnv();
const result = spawnSync(process.execPath, ["--import", rssHookPath, ...testCase.args], {
const spawn = params.spawnSync ?? defaultSpawnSync;
const timeoutMs = params.timeoutMs ?? COMMAND_TIMEOUT_MS;
const result = spawn(process.execPath, ["--import", rssHookPath, ...testCase.args], {
cwd: repoRoot,
env,
encoding: "utf8",
maxBuffer: 20 * 1024 * 1024,
timeout: timeoutMs,
killSignal: "SIGKILL",
});
const stderr = result.stderr ?? "";
const maxRssMb = parseMaxRssMb(stderr);
@@ -193,12 +200,24 @@ function runCase(testCase) {
maxRssMb,
status: "pass",
exitCode: result.status,
signal: result.signal ?? null,
error: null,
};
if (result.error) {
const timedOut = result.error.code === "ETIMEDOUT";
report.status = "fail";
report.error = timedOut
? `${testCase.label} timed out after ${timeoutMs}ms`
: `${testCase.label} failed to start: ${result.error.message}`;
return Object.assign(report, {
failureMessage: formatFailure(testCase, report.error, stderr.trim() || result.stdout || ""),
});
}
if (result.status !== 0) {
report.status = "fail";
report.error = `${testCase.label} exited with ${String(result.status)}`;
const exitDetail = result.status ?? result.signal ?? "unknown";
report.error = `${testCase.label} exited with ${String(exitDetail)}`;
return Object.assign(report, {
failureMessage: formatFailure(testCase, report.error, stderr.trim() || result.stdout || ""),
});
@@ -271,33 +290,59 @@ function writeReport(options, results) {
writeFileSync(options.summaryPath, `${lines.join("\n")}\n`, "utf8");
}
const options = parseArgs(process.argv.slice(2));
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));
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: [] };
}
} finally {
writeReport(options, results);
if (tmpHome) {
rmSync(tmpHome, { recursive: true, force: true });
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 };
}
const failure = results.find((result) => result.status !== "pass");
if (failure?.failureMessage) {
throw new Error(failure.failureMessage);
export const testing = {
cases,
parseArgs,
runCase,
runStartupMemoryCheck,
};
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
runStartupMemoryCheck();
} catch (error) {
console.error(error instanceof Error ? error.stack : String(error));
process.exitCode = 1;
}
}
@@ -3,6 +3,7 @@ import { readdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { testing } from "../../scripts/check-cli-startup-memory.mjs";
const tempRoots: string[] = [];
@@ -39,4 +40,47 @@ describe("check-cli-startup-memory", () => {
expect(result.status).not.toBe(0);
expect(readdirSync(tempRoot)).toEqual([]);
});
it("times out startup probes instead of hanging indefinitely", () => {
if (process.platform !== "darwin" && process.platform !== "linux") {
return;
}
const tempRoot = makeTempRoot();
const seenTimeouts: Array<number | undefined> = [];
const seenKillSignals: Array<string | undefined> = [];
const timeoutError = Object.assign(new Error("spawnSync timed out"), { code: "ETIMEDOUT" });
expect(() =>
testing.runStartupMemoryCheck(
[
"--json",
path.join(tempRoot, "startup-memory.json"),
"--summary",
path.join(tempRoot, "summary.md"),
],
{
platform: "linux",
timeoutMs: 1234,
spawnSync: (
_command: string,
_args: string[],
options: { killSignal?: string; timeout?: number },
) => {
seenTimeouts.push(options.timeout);
seenKillSignals.push(options.killSignal);
return {
error: timeoutError,
signal: "SIGKILL",
status: null,
stderr: "",
stdout: "",
};
},
},
),
).toThrow("--help timed out after 1234ms");
expect(seenTimeouts).toEqual([1234, 1234, 1234]);
expect(seenKillSignals).toEqual(["SIGKILL", "SIGKILL", "SIGKILL"]);
});
});