diff --git a/scripts/profile-extension-memory.mjs b/scripts/profile-extension-memory.mjs index 597b6fda665..24a8dc486e2 100644 --- a/scripts/profile-extension-memory.mjs +++ b/scripts/profile-extension-memory.mjs @@ -10,6 +10,8 @@ const DEFAULT_CONCURRENCY = 6; const DEFAULT_TIMEOUT_MS = 90_000; const DEFAULT_COMBINED_TIMEOUT_MS = 180_000; const DEFAULT_TOP = 10; +const OUTPUT_CAPTURE_MAX_CHARS = 128 * 1024; +const STDERR_PREVIEW_MAX_CHARS = 8 * 1024; const RSS_MARKER = "__OPENCLAW_MAX_RSS_KB__="; function printHelp() { @@ -120,8 +122,49 @@ function parseMaxRssMb(stderr) { return last ? Number(last[1]) / 1024 : null; } -function summarizeStderr(stderr, lines = 8) { - return stderr.trim().split("\n").filter(Boolean).slice(0, lines).join("\n"); +function createOutputCapture() { + return { text: "", truncatedChars: 0 }; +} + +function appendBoundedOutput(capture, chunk, maxChars = OUTPUT_CAPTURE_MAX_CHARS) { + const nextText = capture.text + String(chunk); + if (nextText.length <= maxChars) { + return capture.truncatedChars === 0 + ? { text: nextText, truncatedChars: 0 } + : { text: nextText, truncatedChars: capture.truncatedChars }; + } + const truncatedChars = capture.truncatedChars + nextText.length - maxChars; + return { text: nextText.slice(-maxChars), truncatedChars }; +} + +function formatCapturedOutput(capture) { + if (capture.truncatedChars === 0) { + return capture.text; + } + return `[output truncated ${capture.truncatedChars} chars; showing tail]\n${capture.text}`; +} + +function scanMaxRssMb(tail, chunk, current) { + const text = `${tail}${String(chunk)}`; + const parsed = parseMaxRssMb(text); + const lineBreakIndex = Math.max(text.lastIndexOf("\n"), text.lastIndexOf("\r")); + const openLine = lineBreakIndex === -1 ? text : text.slice(lineBreakIndex + 1); + return { + maxRssMb: parsed ?? current, + tail: openLine.slice(-(RSS_MARKER.length + 32)), + }; +} + +function summarizeStderr(stderr, lines = 8, maxChars = STDERR_PREVIEW_MAX_CHARS) { + const text = stderr.trim().split("\n").filter(Boolean).slice(0, lines).join("\n"); + if (text.length <= maxChars) { + return text; + } + const firstLine = text.split("\n", 1)[0] ?? ""; + const prefix = firstLine.startsWith("[output truncated") ? `${firstLine}\n` : ""; + return `${prefix}[stderr preview truncated ${text.length - maxChars} chars; showing tail]\n${text.slice( + -maxChars, + )}`; } async function runCase({ repoRoot, env, hookPath, name, body, timeoutMs }) { @@ -136,8 +179,10 @@ async function runCase({ repoRoot, env, hookPath, name, body, timeoutMs }) { }, ); - let stdout = ""; - let stderr = ""; + let stdout = createOutputCapture(); + let stderr = createOutputCapture(); + let stderrRssTail = ""; + let maxRssMb = null; let timedOut = false; const timer = setTimeout(() => { timedOut = true; @@ -145,21 +190,25 @@ async function runCase({ repoRoot, env, hookPath, name, body, timeoutMs }) { }, timeoutMs); child.stdout.on("data", (chunk) => { - stdout += String(chunk); + stdout = appendBoundedOutput(stdout, chunk); }); child.stderr.on("data", (chunk) => { - stderr += String(chunk); + const rssScan = scanMaxRssMb(stderrRssTail, chunk, maxRssMb); + stderrRssTail = rssScan.tail; + maxRssMb = rssScan.maxRssMb; + stderr = appendBoundedOutput(stderr, chunk); }); child.on("close", (code, signal) => { clearTimeout(timer); + const stderrText = formatCapturedOutput(stderr); resolve({ name, code, signal, timedOut, - stdout, - stderr, - maxRssMb: parseMaxRssMb(stderr), + stdout: formatCapturedOutput(stdout), + stderr: stderrText, + maxRssMb: maxRssMb ?? parseMaxRssMb(stderrText), }); }); }); @@ -213,9 +262,10 @@ async function main() { writeFileSync( hookPath, [ + "import { writeSync } from 'node:fs';", "process.on('exit', () => {", " const usage = typeof process.resourceUsage === 'function' ? process.resourceUsage() : null;", - ` if (usage && typeof usage.maxRSS === 'number') console.error('${RSS_MARKER}' + String(usage.maxRSS));`, + ` if (usage && typeof usage.maxRSS === 'number') writeSync(2, '${RSS_MARKER}' + String(usage.maxRSS) + '\\n');`, "});", "", ].join("\n"), diff --git a/test/scripts/profile-extension-memory.test.ts b/test/scripts/profile-extension-memory.test.ts index 9529f9c94a4..b69694c6d47 100644 --- a/test/scripts/profile-extension-memory.test.ts +++ b/test/scripts/profile-extension-memory.test.ts @@ -1,9 +1,14 @@ import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; -function runProfileExtensionMemory(args: string[]) { - return spawnSync(process.execPath, ["scripts/profile-extension-memory.mjs", ...args], { - cwd: process.cwd(), +const SCRIPT_PATH = path.resolve("scripts/profile-extension-memory.mjs"); + +function runProfileExtensionMemory(args: string[], cwd = process.cwd()) { + return spawnSync(process.execPath, [SCRIPT_PATH, ...args], { + cwd, encoding: "utf8", }); } @@ -35,4 +40,49 @@ describe("scripts/profile-extension-memory", () => { expect(result.stderr).not.toContain("at "); } }); + + it("bounds noisy child output without losing RSS samples", () => { + const root = mkdtempSync(path.join(tmpdir(), "openclaw-extension-memory-test-")); + try { + const extensionDir = path.join(root, "dist", "extensions", "noisy"); + const reportPath = path.join(root, "report.json"); + mkdirSync(extensionDir, { recursive: true }); + writeFileSync( + path.join(extensionDir, "index.js"), + [ + `const fs = require("node:fs");`, + `fs.writeSync(2, "old stderr " + "x".repeat(160000) + "\\n");`, + `fs.writeSync(1, "old stdout " + "y".repeat(160000) + "\\n");`, + `process.on("exit", () => fs.writeSync(2, "exit tail\\n"));`, + ].join("\n"), + "utf8", + ); + + const result = runProfileExtensionMemory( + [ + "--extension", + "noisy", + "--skip-combined", + "--concurrency", + "1", + "--json", + reportPath, + ], + root, + ); + + expect(result.status, result.stderr).toBe(0); + const report = JSON.parse(readFileSync(reportPath, "utf8")); + expect(report.results).toHaveLength(1); + expect(report.results[0].status).toBe("ok"); + expect(report.results[0].maxRssMb).toEqual(expect.any(Number)); + expect(report.results[0].stderrPreview).toContain("[output truncated"); + expect(report.results[0].stderrPreview).toContain("[stderr preview truncated"); + expect(report.results[0].stderrPreview).toContain("exit tail"); + expect(report.results[0].stderrPreview).not.toContain("old stderr"); + expect(report.results[0].stderrPreview.length).toBeLessThan(9_000); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); });