* fix(bun): report the real runtime and enforce worker memory budgets on Windows Under Bun, REA recorded Node/V8 identity it did not observe and its JavaScript helper processes ran without a memory bound, because Bun ignores the V8 heap flags REA passes. Source-map trace Evidence claimed runtime node-v8 with Bun's synthetic V8 heap limit (the decoder then rejected that limit, so trace_web_source_location always failed), analysis resource failures reported heap limits Bun does not enforce, the HAR decoder claimed a fixed 192 MiB heap, and doctor reported Bun's Node.js compatibility version as the Node version. Identify the runtime once from process.versions. Source-map reports record id bun with the Bun and Node.js compatibility versions and a null v8_heap_limit_bytes; the decoder checks the heap budget only for V8 reports and rejects a report from a runtime other than the launching one. The analysis worker reports a null heap limit under Bun, HAR limitations and failures omit heap sizes under Bun, and the doctor node check reports Bun and passes when its Node.js compatibility version is supported. On Windows under Bun, each helper's owned Job Object now carries a commit limit of its heap budget plus a measured 512 MiB Bun runtime reserve (704 MiB for the codec and HAR decoder, 1536 MiB for the default analyzer). The native addon accepts an optional page-aligned job memory limit on process_spawn, verifies it by readback, associates a private completion port before the child is created, and reports a sticky memoryLimitReached and peak job commit on each poll; inspect reports jobMemoryLimit. A requested limit with an artifact lacking the control refuses the launch, and non-Windows launchers refuse a limit. Reaching the limit is classified by the job notification rather than Bun's exit status: the analyzer fails the current source with a memory resource failure reporting worker_process_memory_limit_bytes, the codec returns a memory resource failure, and the HAR decoder returns its memory failure with process_memory_limit_bytes. Bun source-map Evidence records process_memory_limit_bytes, verified against the launcher. Node.js launches and outputs are unchanged. * fix(windows): drain memory notifications after observing exit --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
281 lines
10 KiB
TypeScript
281 lines
10 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { expect, it } from "vitest";
|
|
import { JavaScriptAnalysisWorker } from "../../../src/javascript/analysis/JavaScriptAnalysisWorker.js";
|
|
import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.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();
|
|
}
|
|
});
|
|
|
|
// 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();
|
|
}
|
|
},
|
|
);
|