import { createHash } from "node:crypto"; import { execFile } from "node:child_process"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { promisify } from "node:util"; import { expect, it } from "vitest"; import { JavaScriptAnalysisWorker } from "../../../src/javascript/analysis/JavaScriptAnalysisWorker.js"; import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js"; import type { OwnedProcessGroup } from "../../../src/process/ProcessOwnership.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; import { javaScriptWorkerMemoryLimitBytes } from "../../../src/process/JavaScriptWorkerMemoryLimit.js"; import type { JavaScriptArtifactFile } from "../../../src/domain/javascript/javascriptArtifactFiles.js"; const source = (path: string, value: string): JavaScriptArtifactFile => ({ path, sha256: createHash("sha256").update(value).digest("hex"), container_sha256: "1".repeat(64), bytes: Buffer.byteLength(value), inventory_artifact_id: "fixture-source", kind: "javascript", unpacked: false, text: { included: true, value }, }); /** The Job Object limit the worker requests for its 128 MiB heap budget, if any. */ const processLimit = javaScriptWorkerMemoryLimitBytes(128); it("reuses one real owned analyzer while retaining distinct source identities and exported returns", async () => { const launches: number[] = []; const worker = new JavaScriptAnalysisWorker( { heapMb: 128, timeoutMs: 30_000 }, async (request) => { const spawned = await spawnOwnedProviderProcess(request); const pid = spawned.process.pid; if (pid === undefined) throw new Error("Owned analyzer has no observed PID"); launches.push(pid); return spawned; }, ); try { const first = source( "first.mjs", "export function create() { return { observed: 7 }; }", ); const second = source( "second.mjs", "export function next() { return { observed: 8 }; }", ); for (const [index, file] of [first, second].entries()) { const result = await worker.analyze(file, 100); if (!result.ok) throw result.error.error; expect(result.value.javascript.parse_status).toBe("complete"); expect( result.value.module?.callables[0]?.returnSites[0]?.value, ).toMatchObject({ status: "object", properties: [ { name: "observed", value: { status: "literal", value: index + 7 } }, ], }); expect( result.value.projection?.nodes.every( ({ identity }) => identity.artifact_sha256 === file.sha256, ), ).toBe(true); expect( result.value.projection?.nodes.some( ({ identity }) => identity.module_path === file.path, ), ).toBe(true); } expect(launches).toHaveLength(1); } finally { await worker.close(); } }); it("reports an actual startup deadline and closes its owned resources before subsequent analysis", async () => { const file = source("app.mjs", "export const observed = 7;"); const expired = new JavaScriptAnalysisWorker({ heapMb: 128, timeoutMs: 1 }); try { const result = await expired.analyze(file, 100); if (result.ok) throw new Error( "A one-millisecond fresh process deadline unexpectedly completed", ); expect(result.error.error).toMatchObject({ _tag: "AnalysisTimeoutError", timeoutMs: 1, }); } finally { await expired.close(); } const next = new JavaScriptAnalysisWorker({ heapMb: 128, timeoutMs: 30_000 }); try { const result = await next.analyze(file, 100); if (!result.ok) throw result.error.error; expect(result.value.javascript.parse_status).toBe("complete"); } finally { await next.close(); } }); it("preserves a failed owned cleanup retry when the next caller cancels before acquisition", async () => { const controller = new AbortController(); let releaseAllowed = false; let cleanupAttempts = 0; const worker = new JavaScriptAnalysisWorker( { heapMb: 128, timeoutMs: 30_000 }, async (request) => { const launched = await spawnOwnedProviderProcess(request); return { ...launched, async cleanup() { cleanupAttempts += 1; if (!releaseAllowed) { if (cleanupAttempts > 1) controller.abort(); throw new Error("Fixture owned cleanup is unavailable"); } return launched.cleanup === undefined ? cleanupOwnedProcessGroup(launched.ownership) : launched.cleanup(); }, }; }, ); const next = new JavaScriptAnalysisWorker({ heapMb: 128, timeoutMs: 30_000 }); try { const file = source("app.mjs", "export const observed = 7;"); const first = await worker.analyze(file, 100); if (!first.ok) throw first.error.error; const closing = worker.close(); await expect(closing).rejects.toBeInstanceOf(ProviderCleanupError); await expect(closing).rejects.toMatchObject({ cause: { message: "Fixture owned cleanup is unavailable" }, }); const interrupted = await next.analyze(file, 100, controller.signal); if (interrupted.ok) throw new Error("Unreleased ownership must block subsequent analysis"); expect(controller.signal.aborted).toBe(true); expect(projectAnalysisError(interrupted.error.error)).toMatchObject({ code: "cleanup_incomplete", }); releaseAllowed = true; await worker.close(); const recovered = await next.analyze(file, 100); if (!recovered.ok) throw recovered.error.error; expect(recovered.value.javascript.parse_status).toBe("complete"); } finally { releaseAllowed = true; await worker.close(); await next.close(); } }); it("ends worker stdin and unreferences process handles when cleanup reports inspection uncertainty", async () => { let stdinEnded = false; let unreferenced = false; let permitCleanup = false; let childStdin: typeof process.stdin | undefined; const worker = new JavaScriptAnalysisWorker( { heapMb: 128, timeoutMs: 30_000 }, async (request) => { const launched = await spawnOwnedProviderProcess(request); const originalUnref = launched.process.unref; launched.process.unref = () => { unreferenced = true; originalUnref?.call(launched.process); }; childStdin = launched.process.stdin as unknown as typeof process.stdin; launched.process.stdin?.once("finish", () => { stdinEnded = true; }); return { ...launched, cleanup: async () => permitCleanup ? launched.cleanup === undefined ? cleanupOwnedProcessGroup(launched.ownership) : launched.cleanup() : { cleaned: false, reason: "process table could not be inspected: spawn EPERM", }, }; }, ); try { const file = source("app.mjs", "export const observed = 7;"); const result = await worker.analyze(file, 100); if (!result.ok) throw result.error.error; const closing = worker.close(); await expect(closing).rejects.toBeInstanceOf(ProviderCleanupError); await expect(closing).rejects.toMatchObject({ cause: { message: "process table could not be inspected: spawn EPERM", }, }); expect(childStdin?.writableEnded || stdinEnded).toBe(true); expect(unreferenced).toBe(true); const errorListeners = childStdin?.listenerCount("error"); for (let retry = 0; retry < 3; retry++) await expect(worker.close()).rejects.toBeInstanceOf(ProviderCleanupError); expect(childStdin?.listenerCount("error")).toBeLessThanOrEqual( errorListeners ?? 0, ); permitCleanup = true; await worker.close(); } finally { permitCleanup = true; await worker.close().catch(() => {}); } }); it("lets a separate host exit while its live worker cleanup remains incomplete", async () => { const root = await createTestTempDirectory("rea-worker-incomplete-host-"); const ownershipPath = join(root, "ownership.json"); const workerUrl = new URL( "../../../dist/javascript/analysis/JavaScriptAnalysisWorker.js", import.meta.url, ).href; const launcherUrl = new URL( "../../../dist/process/ProviderProcess.js", import.meta.url, ).href; const script = ` import { writeFile } from "node:fs/promises"; import { pathToFileURL } from "node:url"; import { JavaScriptAnalysisWorker } from ${JSON.stringify(workerUrl)}; import { spawnOwnedProviderProcess } from ${JSON.stringify(launcherUrl)}; const worker = new JavaScriptAnalysisWorker({ heapMb: 128, timeoutMs: 10000 }, async request => { const entry = pathToFileURL(request.arguments.at(-1)).href; const launched = await spawnOwnedProviderProcess({ ...request, arguments: [ ...request.arguments.slice(0, -1), "--input-type=module", "-e", "const interval = setInterval(() => {}, 1000); setTimeout(() => clearInterval(interval), 60000); await import(" + JSON.stringify(entry) + ");" ] }); // The launcher is deliberately orphaned when this host exits; cleanup still // requires its run token and executable identity, rather than its old parent. const { expectedParentPid, ...ownership } = launched.ownership; await writeFile(${JSON.stringify(ownershipPath)}, JSON.stringify(ownership)); return { ...launched, cleanup: async () => ({ cleaned: false, reason: "fixture inspection unavailable" }) }; }); const analyzed = await worker.analyze(${JSON.stringify(source("app.mjs", "export const value = 1;"))}, 100); if (!analyzed.ok) throw analyzed.error.error; try { await worker.close(); throw new Error("cleanup unexpectedly completed"); } catch (error) { if (error.cleanupIncomplete !== true) throw error; process.stdout.write(JSON.stringify({ code: "cleanup_incomplete", reason: error.cause.message }) + "\\n"); } `; try { const result = await promisify(execFile)( process.execPath, ["--input-type=module", "-e", script], { timeout: 10_000 }, ); expect(JSON.parse(result.stdout)).toMatchObject({ code: "cleanup_incomplete", reason: "fixture inspection unavailable", }); } finally { const ownership = JSON.parse( await readFile(ownershipPath, "utf8"), ) as OwnedProcessGroup; expect(await cleanupOwnedProcessGroup(ownership)).toMatchObject({ cleaned: true, }); } }); // The handshake mirrors the real server: only Node.js reports an enforced V8 heap limit. const resourceFailureWorker = (heapLimit: string) => new JavaScriptAnalysisWorker({ heapMb: 128, timeoutMs: 30_000 }, (request) => spawnOwnedProviderProcess({ ...request, arguments: [ "-e", `const heap = ${heapLimit}; process.stdout.write(JSON.stringify({ kind: "ready", heap_limit_bytes: heap }) + "\\n"); require("node:readline").createInterface({ input: process.stdin }).once("line", (line) => { const { id } = JSON.parse(line); process.stdout.write(JSON.stringify({ kind: "failure", id, reason: "resource", resource: "memory", limits: null, message: "fixture" }) + "\\n"); });`, ], }), ); it("reports the worker heap limit only when the runtime enforces V8 heap flags", async () => { const worker = resourceFailureWorker( 'process.versions.bun === undefined ? require("node:v8").getHeapStatistics().heap_size_limit : null', ); try { const result = await worker.analyze( source("app.mjs", "export const observed = 7;"), 100, ); if (result.ok) throw new Error("expected a reported resource failure"); expect(result.error.error).toMatchObject({ _tag: "AnalysisResourceConstraintError", reportedLimits: process.versions.bun === undefined ? { worker_old_space_mb: 128, worker_heap_limit_bytes: expect.any(Number), worker_timeout_ms: 30_000, } : { worker_heap_limit_bytes: null, ...(processLimit === null ? {} : { worker_process_memory_limit_bytes: processLimit }), worker_timeout_ms: 30_000, }, }); } finally { await worker.close(); } }); it("rejects a worker heap report that contradicts the launching runtime", async () => { const worker = resourceFailureWorker( "process.versions.bun === undefined ? null : 1", ); try { const result = await worker.analyze( source("app.mjs", "export const observed = 7;"), 100, ); if (result.ok) throw new Error("expected a handshake failure"); expect(result.error.error._tag).toBe("AnalysisOutputError"); } finally { await worker.close(); } }); // The launcher runs a real child without a job limit, so any artifact can run // it, and its handle reports the notification an owned Windows job records // before exit. Bun exits 3 (panic) or 1 (exception) at a reached job limit. it.each([true, false])( "classifies a worker exit by the job memory notification (reached: %s)", async (reached) => { const worker = new JavaScriptAnalysisWorker( { heapMb: 128, timeoutMs: 30_000 }, async ({ memoryLimitBytes, ...request }) => { expect(memoryLimitBytes).toBe(processLimit ?? undefined); const spawned = await spawnOwnedProviderProcess({ ...request, arguments: [ "-e", `const heap = process.versions.bun === undefined ? require("node:v8").getHeapStatistics().heap_size_limit : null; process.stdout.write(JSON.stringify({ kind: "ready", heap_limit_bytes: heap }) + "\\n"); require("node:readline").createInterface({ input: process.stdin }).once("line", () => { process.stderr.write("panic: out of memory"); process.exit(3); });`, ], }); let exited = false; spawned.process.once("exit", () => { exited = true; }); Object.defineProperty(spawned.process, "memoryLimitReached", { get: () => reached && exited, }); return spawned; }, ); try { const result = await worker.analyze( source("app.mjs", "export const observed = 7;"), 100, ); if (result.ok) throw new Error("expected the worker to fail"); expect(result.error.error).toMatchObject( reached ? { _tag: "AnalysisResourceConstraintError", resource: "memory", reason: expect.stringContaining("process memory limit"), reportedLimits: processLimit === null ? expect.not.objectContaining({ worker_process_memory_limit_bytes: expect.anything(), }) : { worker_process_memory_limit_bytes: processLimit }, capturedOutput: { stderr: "panic: out of memory" }, } : { _tag: "ProviderAdapterError", diagnostics: { exit_code: 3 }, }, ); } finally { await worker.close(); } }, );