diff --git a/CHANGELOG.md b/CHANGELOG.md index d92d8e300b69..2698ecf3d131 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/scripts/check-cli-startup-memory.mjs b/scripts/check-cli-startup-memory.mjs index 2b8c2cb7e40f..0a56146531b2 100644 --- a/scripts/check-cli-startup-memory.mjs +++ b/scripts/check-cli-startup-memory.mjs @@ -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; + } } diff --git a/test/scripts/check-cli-startup-memory.test.ts b/test/scripts/check-cli-startup-memory.test.ts index e099c1670978..67c409710b46 100644 --- a/test/scripts/check-cli-startup-memory.test.ts +++ b/test/scripts/check-cli-startup-memory.test.ts @@ -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 = []; + const seenKillSignals: Array = []; + 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"]); + }); });