From c0e0115b3118b17567b70d3b28f8e426d7437e98 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 17:42:48 -0700 Subject: [PATCH] CI: add CLI startup memory regression check --- .github/workflows/ci.yml | 23 ++++++ package.json | 1 + scripts/check-cli-startup-memory.mjs | 112 +++++++++++++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 scripts/check-cli-startup-memory.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a11e7331e5a..9922ceb12f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -232,6 +232,29 @@ jobs: - name: Enforce safe external URL opening policy run: pnpm lint:ui:no-raw-window-open + startup-memory: + name: "startup-memory" + needs: [docs-scope, changed-scope] + if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' + runs-on: blacksmith-16vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + submodules: false + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + install-bun: "false" + use-sticky-disk: "false" + + - name: Build dist + run: pnpm build + + - name: Check CLI startup memory + run: pnpm test:startup:memory + # Validate docs (format, lint, broken links) only when docs files changed. check-docs: needs: [docs-scope] diff --git a/package.json b/package.json index d8f1e530d9b..2fc0ec447d0 100644 --- a/package.json +++ b/package.json @@ -336,6 +336,7 @@ "test:perf:budget": "node scripts/test-perf-budget.mjs", "test:perf:hotspots": "node scripts/test-hotspots.mjs", "test:sectriage": "pnpm exec vitest run --config vitest.gateway.config.ts && vitest run --config vitest.unit.config.ts --exclude src/daemon/launchd.integration.test.ts --exclude src/process/exec.test.ts", + "test:startup:memory": "node scripts/check-cli-startup-memory.mjs", "test:ui": "pnpm lint:ui:no-raw-window-open && pnpm --dir ui test", "test:voicecall:closedloop": "vitest run extensions/voice-call/src/manager.test.ts extensions/voice-call/src/media-stream.test.ts src/plugins/voice-call.plugin.test.ts --maxWorkers=1", "test:watch": "vitest", diff --git a/scripts/check-cli-startup-memory.mjs b/scripts/check-cli-startup-memory.mjs new file mode 100644 index 00000000000..dbf666e1bfb --- /dev/null +++ b/scripts/check-cli-startup-memory.mjs @@ -0,0 +1,112 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync } 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); +} + +const repoRoot = process.cwd(); +const tmpHome = mkdtempSync(path.join(os.tmpdir(), "openclaw-startup-memory-")); + +const DEFAULT_LIMITS_MB = { + help: 500, + statusJson: 900, + gatewayStatus: 900, +}; + +const cases = [ + { + id: "help", + label: "--help", + args: ["node", "openclaw.mjs", "--help"], + limitMb: Number(process.env.OPENCLAW_STARTUP_MEMORY_HELP_MB ?? DEFAULT_LIMITS_MB.help), + }, + { + id: "statusJson", + label: "status --json", + args: ["node", "openclaw.mjs", "status", "--json"], + limitMb: Number( + process.env.OPENCLAW_STARTUP_MEMORY_STATUS_JSON_MB ?? DEFAULT_LIMITS_MB.statusJson, + ), + }, + { + id: "gatewayStatus", + label: "gateway status", + args: ["node", "openclaw.mjs", "gateway", "status"], + limitMb: Number( + process.env.OPENCLAW_STARTUP_MEMORY_GATEWAY_STATUS_MB ?? DEFAULT_LIMITS_MB.gatewayStatus, + ), + }, +]; + +function parseMaxRssMb(stderr) { + if (isLinux) { + const match = stderr.match(/^\s*Maximum resident set size \(kbytes\):\s*(\d+)\s*$/im); + if (!match) { + return null; + } + return Number(match[1]) / 1024; + } + const match = stderr.match(/^\s*(\d+)\s+maximum resident set size\s*$/im); + if (!match) { + return null; + } + return Number(match[1]) / (1024 * 1024); +} + +function runCase(testCase) { + const env = { + ...process.env, + HOME: tmpHome, + XDG_CONFIG_HOME: path.join(tmpHome, ".config"), + XDG_DATA_HOME: path.join(tmpHome, ".local", "share"), + XDG_CACHE_HOME: path.join(tmpHome, ".cache"), + }; + const timeArgs = isLinux ? ["-v", ...testCase.args] : ["-l", ...testCase.args]; + const result = spawnSync("/usr/bin/time", timeArgs, { + cwd: repoRoot, + env, + encoding: "utf8", + maxBuffer: 20 * 1024 * 1024, + }); + const stderr = result.stderr ?? ""; + const maxRssMb = parseMaxRssMb(stderr); + const matrixBootstrapWarning = /matrix: crypto runtime bootstrap failed/i.test(stderr); + + if (result.status !== 0) { + throw new Error( + `${testCase.label} exited with ${String(result.status)}\n${stderr.trim() || result.stdout || ""}`, + ); + } + if (maxRssMb == null) { + throw new Error(`${testCase.label} did not report max RSS\n${stderr.trim()}`); + } + if (matrixBootstrapWarning) { + throw new Error(`${testCase.label} triggered Matrix crypto bootstrap during startup`); + } + if (maxRssMb > testCase.limitMb) { + throw new Error( + `${testCase.label} used ${maxRssMb.toFixed(1)} MB RSS (limit ${testCase.limitMb} MB)`, + ); + } + + console.log( + `[startup-memory] ${testCase.label}: ${maxRssMb.toFixed(1)} MB RSS (limit ${testCase.limitMb} MB)`, + ); +} + +try { + for (const testCase of cases) { + runCase(testCase); + } +} finally { + rmSync(tmpHome, { recursive: true, force: true }); +}