diff --git a/scripts/embedded-run-abort-leak.ts b/scripts/embedded-run-abort-leak.ts index 4c90a3888ba9..88bbba6a7b4c 100644 --- a/scripts/embedded-run-abort-leak.ts +++ b/scripts/embedded-run-abort-leak.ts @@ -19,9 +19,9 @@ import * as fs from "node:fs"; import * as path from "node:path"; import * as v8 from "node:v8"; -import { abortable as productionAbortable } from "../src/agents/embedded-agent-runner/run/abortable.js"; type Mode = "production" | "closure-extracted" | "closure-inline" | "synthetic-leak"; +type Abortable = (signal: AbortSignal, promise: Promise) => Promise; type Options = { iters: number; @@ -50,11 +50,11 @@ function parseArgs(argv: string[]): Options { const next = argv[i + 1]; switch (arg) { case "--iters": - opts.iters = Number.parseInt(next ?? "", 10); + opts.iters = parsePositiveInt(next, arg); i += 1; break; case "--batches": - opts.batches = Number.parseInt(next ?? "", 10); + opts.batches = parsePositiveInt(next, arg); i += 1; break; case "--snap-dir": @@ -77,15 +77,15 @@ function parseArgs(argv: string[]): Options { i += 1; break; case "--max-rss-growth-mb": - opts.maxRssGrowthMb = Number.parseInt(next ?? "", 10); + opts.maxRssGrowthMb = parseNonNegativeInt(next, arg); i += 1; break; case "--max-tracked-retention": - opts.maxTrackedRetention = Number.parseInt(next ?? "", 10); + opts.maxTrackedRetention = parseNonNegativeInt(next, arg); i += 1; break; case "--scope-bytes": - opts.scopeBytes = Number.parseInt(next ?? "", 10); + opts.scopeBytes = parsePositiveInt(next, arg); i += 1; break; case "--quiet": @@ -109,6 +109,38 @@ function parseArgs(argv: string[]): Options { return opts; } +function parsePositiveInt(raw: string | undefined, flag: string): number { + const value = parseStrictInt(raw, flag, "positive"); + if (value <= 0) { + fail(`${flag} must be a positive integer`); + } + return value; +} + +function parseNonNegativeInt(raw: string | undefined, flag: string): number { + const value = parseStrictInt(raw, flag, "non-negative"); + if (value < 0) { + fail(`${flag} must be a non-negative integer`); + } + return value; +} + +function parseStrictInt( + raw: string | undefined, + flag: string, + label: "positive" | "non-negative", +): number { + const text = (raw ?? "").trim(); + if (!/^\d+$/u.test(text)) { + fail(`${flag} must be a ${label} integer`); + } + const value = Number(text); + if (!Number.isSafeInteger(value)) { + fail(`${flag} must be a ${label} integer`); + } + return value; +} + function printUsage(): void { process.stderr.write( [ @@ -137,6 +169,14 @@ const FINALIZED = { count: 0 }; const finalizer = new FinalizationRegistry(() => { FINALIZED.count += 1; }); +let productionAbortable: Abortable | null = null; + +async function loadProductionAbortable(): Promise { + const module = (await import("../src/agents/embedded-agent-runner/run/abortable.js")) as { + abortable: Abortable; + }; + productionAbortable = module.abortable; +} function abortableExtracted(signal: AbortSignal, promise: Promise): Promise { if (signal.aborted) { @@ -179,6 +219,9 @@ function runOnce(mode: Mode, scopeBytes: number, iter: number): void { KEEP_ALIVE.push(neverSettling); if (mode === "production") { + if (!productionAbortable) { + throw new Error("production abortable is not loaded"); + } void productionAbortable(ac.signal, neverSettling).catch(() => {}); } else if (mode === "closure-extracted") { void abortableExtracted(ac.signal, neverSettling).catch(() => {}); @@ -243,6 +286,9 @@ function fmtBytes(bytes: number): string { async function main(): Promise { const opts = parseArgs(process.argv.slice(2)); + if (opts.mode === "production") { + await loadProductionAbortable(); + } if (typeof globalThis.gc !== "function") { fail("--expose-gc is required (run with: node --expose-gc ...)"); } diff --git a/test/scripts/embedded-run-abort-leak.test.ts b/test/scripts/embedded-run-abort-leak.test.ts new file mode 100644 index 000000000000..171e5d664a19 --- /dev/null +++ b/test/scripts/embedded-run-abort-leak.test.ts @@ -0,0 +1,52 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const tempRoots: string[] = []; + +function makeTempRoot(): string { + const root = mkdtempSync(path.join(tmpdir(), "openclaw-embedded-abort-leak-test-")); + tempRoots.push(root); + return root; +} + +function runHarness(args: string[]) { + return spawnSync( + process.execPath, + ["--import", "tsx", "--expose-gc", "scripts/embedded-run-abort-leak.ts", ...args], + { + cwd: process.cwd(), + encoding: "utf8", + }, + ); +} + +afterEach(() => { + for (const root of tempRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe("scripts/embedded-run-abort-leak", () => { + it("rejects loose numeric thresholds before writing heap snapshots", () => { + const cases = [ + ["--iters", "1e3", "positive"], + ["--batches", "2abc", "positive"], + ["--max-rss-growth-mb", "0x10", "non-negative"], + ["--max-tracked-retention", "abc", "non-negative"], + ["--scope-bytes", "1mb", "positive"], + ] as const; + + for (const [flag, value, label] of cases) { + const snapDir = makeTempRoot(); + const result = runHarness(["--snap-dir", snapDir, flag, value, "--quiet"]); + + expect(result.status).toBe(2); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain(`error: ${flag} must be a ${label} integer`); + expect(readdirSync(snapDir)).toEqual([]); + } + }); +});