diff --git a/scripts/profile-extension-memory.mjs b/scripts/profile-extension-memory.mjs index 77e5a68c13a0..349c53722593 100644 --- a/scripts/profile-extension-memory.mjs +++ b/scripts/profile-extension-memory.mjs @@ -4,6 +4,7 @@ import { spawn } from "node:child_process"; import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import { ensureExtensionMemoryBuild } from "./ensure-extension-memory-build.mjs"; import { formatErrorMessage } from "./lib/error-format.mjs"; @@ -168,9 +169,17 @@ function summarizeStderr(stderr, lines = 8, maxChars = STDERR_PREVIEW_MAX_CHARS) )}`; } -async function runCase({ repoRoot, env, hookPath, name, body, timeoutMs }) { +export async function runCase({ + repoRoot, + env, + hookPath, + name, + body, + timeoutMs, + spawnImpl = spawn, +}) { return await new Promise((resolve) => { - const child = spawn( + const child = spawnImpl( process.execPath, ["--import", hookPath, "--input-type=module", "--eval", body], { @@ -185,10 +194,21 @@ async function runCase({ repoRoot, env, hookPath, name, body, timeoutMs }) { let stderrRssTail = ""; let maxRssMb = null; let timedOut = false; + let settled = false; const timer = setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, timeoutMs); + timer.unref?.(); + + function settle(result) { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + resolve(result); + } child.stdout.on("data", (chunk) => { stdout = appendBoundedOutput(stdout, chunk); @@ -199,14 +219,27 @@ async function runCase({ repoRoot, env, hookPath, name, body, timeoutMs }) { maxRssMb = rssScan.maxRssMb; stderr = appendBoundedOutput(stderr, chunk); }); - child.on("close", (code, signal) => { - clearTimeout(timer); + child.on("error", (error) => { const stderrText = formatCapturedOutput(stderr); - resolve({ + settle({ + name, + code: null, + signal: null, + timedOut, + error: formatErrorMessage(error), + stdout: formatCapturedOutput(stdout), + stderr: stderrText, + maxRssMb: maxRssMb ?? parseMaxRssMb(stderrText), + }); + }); + child.on("close", (code, signal) => { + const stderrText = formatCapturedOutput(stderr); + settle({ name, code, signal, timedOut, + error: null, stdout: formatCapturedOutput(stdout), stderr: stderrText, maxRssMb: maxRssMb ?? parseMaxRssMb(stderrText), @@ -411,9 +444,11 @@ async function main() { } } -try { - await main(); -} catch (error) { - console.error(`[extension-memory] ${formatErrorMessage(error)}`); - process.exit(1); +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + await main(); + } catch (error) { + console.error(`[extension-memory] ${formatErrorMessage(error)}`); + process.exit(1); + } } diff --git a/test/scripts/profile-extension-memory.test.ts b/test/scripts/profile-extension-memory.test.ts index b69694c6d476..d50c8e18afce 100644 --- a/test/scripts/profile-extension-memory.test.ts +++ b/test/scripts/profile-extension-memory.test.ts @@ -1,8 +1,10 @@ import { spawnSync } from "node:child_process"; +import { EventEmitter } from "node:events"; 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"; +import { runCase } from "../../scripts/profile-extension-memory.mjs"; const SCRIPT_PATH = path.resolve("scripts/profile-extension-memory.mjs"); @@ -59,15 +61,7 @@ describe("scripts/profile-extension-memory", () => { ); const result = runProfileExtensionMemory( - [ - "--extension", - "noisy", - "--skip-combined", - "--concurrency", - "1", - "--json", - reportPath, - ], + ["--extension", "noisy", "--skip-combined", "--concurrency", "1", "--json", reportPath], root, ); @@ -85,4 +79,37 @@ describe("scripts/profile-extension-memory", () => { rmSync(root, { recursive: true, force: true }); } }); + + it("resolves spawn errors without waiting for the timeout", async () => { + const startedAt = Date.now(); + const result = await runCase({ + repoRoot: process.cwd(), + env: process.env, + hookPath: "missing-hook.mjs", + name: "spawn-error", + body: "", + timeoutMs: 30_000, + spawnImpl: () => { + const child = new EventEmitter() as EventEmitter & { + kill: () => boolean; + stderr: EventEmitter; + stdout: EventEmitter; + }; + child.stderr = new EventEmitter(); + child.stdout = new EventEmitter(); + child.kill = () => true; + queueMicrotask(() => child.emit("error", new Error("spawn denied"))); + return child; + }, + }); + + expect(Date.now() - startedAt).toBeLessThan(1000); + expect(result).toMatchObject({ + code: null, + error: "spawn denied", + name: "spawn-error", + signal: null, + timedOut: false, + }); + }); });