mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(cli): bound startup memory probes
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user