* 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>
338 lines
12 KiB
TypeScript
338 lines
12 KiB
TypeScript
import { fileURLToPath } from "node:url";
|
|
import { access } from "node:fs/promises";
|
|
import { expect, it, vi } from "vitest";
|
|
import { SourceMapDecoder } from "../../../src/javascript/sourceMaps/SourceMapDecoder.js";
|
|
import {
|
|
SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES,
|
|
type SourceMapDecoderDependencies,
|
|
} from "../../../src/javascript/sourceMaps/SourceMapCommand.js";
|
|
import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js";
|
|
import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js";
|
|
import { waitForProviderProcessReady } from "../../fixtures/providerProcess.js";
|
|
import {
|
|
webSourceLocationArgs,
|
|
webSourceLocationFixture,
|
|
} from "../../fixtures/webSourceLocation.js";
|
|
|
|
const realDecoder = (dependencies: SourceMapDecoderDependencies = {}) =>
|
|
new SourceMapDecoder({
|
|
...dependencies,
|
|
launcher:
|
|
dependencies.launcher ??
|
|
((options) =>
|
|
spawnOwnedProviderProcess({
|
|
...options,
|
|
arguments: options.arguments.map((argument) =>
|
|
argument.endsWith("/SourceMapCodecProcess.js") ||
|
|
argument.endsWith("\\SourceMapCodecProcess.js")
|
|
? fileURLToPath(
|
|
new URL(
|
|
"../../../dist/javascript/sourceMaps/SourceMapCodecProcess.js",
|
|
import.meta.url,
|
|
),
|
|
)
|
|
: argument,
|
|
),
|
|
})),
|
|
});
|
|
|
|
const fixture = webSourceLocationFixture();
|
|
const input = {
|
|
text: fixture.sourceMap.text,
|
|
url: fixture.sourceMap.url,
|
|
path: fixture.sourceMap.file.path,
|
|
position: webSourceLocationArgs.generated_position,
|
|
};
|
|
|
|
it("starts the codec deadline after cold ownership preparation", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
let prepared = false;
|
|
const responsePromise = realDecoder({
|
|
prepareOwnership: async (signal) => {
|
|
await vi.advanceTimersByTimeAsync(20_001);
|
|
expect(signal?.aborted).not.toBe(true);
|
|
prepared = true;
|
|
},
|
|
launcher: (options) => {
|
|
expect(prepared).toBe(true);
|
|
expect(options.signal?.aborted).not.toBe(true);
|
|
return Promise.reject(new Error("observed launch after preparation"));
|
|
},
|
|
}).trace(input);
|
|
const response = await responsePromise;
|
|
if (response.ok) throw new Error("expected launch failure");
|
|
expect(response.error).toMatchObject({
|
|
diagnostics: { error_message: "observed launch after preparation" },
|
|
});
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("runs the real pinned codec with an independent heap and removes its private request directory", async () => {
|
|
let path: string | undefined;
|
|
const environment = {
|
|
...process.env,
|
|
NODE_OPTIONS: "--require=/rea-file-that-does-not-exist",
|
|
};
|
|
const decoder = realDecoder({
|
|
environment,
|
|
createRuntime: async () => {
|
|
const root = await PrivateRuntimeRoot.create();
|
|
path = root.path;
|
|
return root;
|
|
},
|
|
});
|
|
const response = await decoder.trace(input);
|
|
if (!response.ok) throw response.error;
|
|
expect(response.value).toMatchObject({
|
|
engine: { id: "jridgewell-trace-mapping", version: "0.3.31" },
|
|
matches: [{ content: { text: "original" } }],
|
|
});
|
|
if (process.versions.bun === undefined) {
|
|
expect(response.value.runtime).toMatchObject({
|
|
id: "node-v8",
|
|
version: process.version,
|
|
});
|
|
expect(response.value.runtime.v8_heap_limit_bytes).toBeLessThanOrEqual(
|
|
256 * 1024 * 1024,
|
|
);
|
|
} else
|
|
expect(response.value.runtime).toEqual({
|
|
id: "bun",
|
|
version: process.versions.bun,
|
|
node_compatibility_version: process.versions.node,
|
|
v8_heap_limit_bytes: null,
|
|
// Windows enforces a Job Object commit limit in place of the heap flags.
|
|
process_memory_limit_bytes: SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES,
|
|
});
|
|
expect(environment.NODE_OPTIONS).toBe(
|
|
"--require=/rea-file-that-does-not-exist",
|
|
);
|
|
if (path === undefined) throw new Error("runtime not acquired");
|
|
await expect(access(path)).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
it.each([
|
|
{ text: "{", tag: "AnalysisInputError" },
|
|
{
|
|
text: JSON.stringify({
|
|
version: 3,
|
|
sections: [
|
|
{
|
|
offset: { line: 0, column: 0 },
|
|
url: "https://app.test/external.map",
|
|
},
|
|
],
|
|
}),
|
|
tag: "AnalysisCapabilityUnavailableError",
|
|
},
|
|
{
|
|
text: JSON.stringify({
|
|
version: 3,
|
|
sections: [{ offset: { line: 1000000000, column: 0 }, map: {} }],
|
|
}),
|
|
tag: "ArtifactOperationError",
|
|
},
|
|
])("preserves actual producer failures: $tag", async ({ text, tag }) => {
|
|
const response = await realDecoder().trace({ ...input, text });
|
|
if (response.ok) throw new Error("expected failed map");
|
|
expect(response.error._tag).toBe(tag);
|
|
});
|
|
it("reports cleanup failure with the actual owning operation", async () => {
|
|
const response = await realDecoder({
|
|
createRuntime: async () => {
|
|
const root = await PrivateRuntimeRoot.create();
|
|
return {
|
|
path: root.path,
|
|
close: async () => {
|
|
await root.close();
|
|
throw new Error("observed cleanup failure");
|
|
},
|
|
};
|
|
},
|
|
}).trace(input);
|
|
if (response.ok) throw new Error("expected cleanup failure");
|
|
expect(response.error).toMatchObject({
|
|
cleanupIncomplete: true,
|
|
operation: "trace_web_source_location",
|
|
diagnostics: { cleanup_failures: ["observed cleanup failure"] },
|
|
});
|
|
});
|
|
it("cancels a real owned process and removes the runtime request", async () => {
|
|
const controller = new AbortController();
|
|
let path: string | undefined;
|
|
const response = await realDecoder({
|
|
createRuntime: async () => {
|
|
const root = await PrivateRuntimeRoot.create();
|
|
path = root.path;
|
|
return root;
|
|
},
|
|
launcher: async (options) => {
|
|
const child = await spawnOwnedProviderProcess({
|
|
...options,
|
|
arguments: [
|
|
"-e",
|
|
'process.stdout.write("ready\\n"); setInterval(() => undefined, 1000)',
|
|
],
|
|
});
|
|
await waitForProviderProcessReady(child.process);
|
|
setImmediate(() => controller.abort());
|
|
return child;
|
|
},
|
|
}).trace(input, { signal: controller.signal });
|
|
if (response.ok) throw new Error("expected cancellation");
|
|
expect(response.error._tag).toBe("AnalysisCancelledError");
|
|
if (path === undefined) throw new Error("runtime not acquired");
|
|
await expect(access(path)).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
it("retains final diagnostic bytes for an actual failed codec process", async () => {
|
|
const response = await realDecoder({
|
|
launcher: (options) =>
|
|
spawnOwnedProviderProcess({
|
|
...options,
|
|
arguments: [
|
|
"-e",
|
|
'process.stderr.write("final diagnostic"); process.exitCode = 7',
|
|
],
|
|
}),
|
|
}).trace(input);
|
|
if (response.ok) throw new Error("expected exit failure");
|
|
expect(response.error).toMatchObject({
|
|
diagnostics: { exit_code: 7, stderr: "final diagnostic" },
|
|
});
|
|
});
|
|
it("reports complete evidence amplification as a resource failure before serializing an oversized reply", async () => {
|
|
const text = JSON.stringify({
|
|
version: 3,
|
|
sources: ["large.ts"],
|
|
sourcesContent: ["a".repeat(600000)],
|
|
names: [],
|
|
mappings: Array.from({ length: 60 }, () => "AAAA").join(","),
|
|
});
|
|
const response = await realDecoder().trace({ ...input, text });
|
|
if (response.ok) throw new Error("expected complete-evidence budget failure");
|
|
expect(response.error).toMatchObject({
|
|
_tag: "ArtifactOperationError",
|
|
reason: "limit",
|
|
detail: expect.stringContaining("no partial"),
|
|
});
|
|
});
|
|
it("rejects a malformed actual subprocess reply", async () => {
|
|
const response = await realDecoder({
|
|
launcher: (options) =>
|
|
spawnOwnedProviderProcess({
|
|
...options,
|
|
arguments: ["-e", 'process.stdout.write("not JSON")'],
|
|
}),
|
|
}).trace(input);
|
|
if (response.ok) throw new Error("expected malformed reply");
|
|
expect(response.error._tag).toBe("AnalysisOutputError");
|
|
});
|
|
it("rejects a codec report from a runtime other than the launching one", async () => {
|
|
const actual = await realDecoder().trace(input);
|
|
if (!actual.ok) throw actual.error;
|
|
const replay = (report: unknown) =>
|
|
realDecoder({
|
|
launcher: (options) =>
|
|
spawnOwnedProviderProcess({
|
|
...options,
|
|
arguments: [
|
|
"-e",
|
|
`process.stdout.write(${JSON.stringify(JSON.stringify({ state: "success", report }))})`,
|
|
],
|
|
}),
|
|
}).trace(input);
|
|
const unchanged = await replay(actual.value);
|
|
if (!unchanged.ok) throw unchanged.error;
|
|
expect(unchanged.value).toEqual(actual.value);
|
|
const response = await replay({
|
|
...actual.value,
|
|
runtime:
|
|
actual.value.runtime.id === "bun"
|
|
? { id: "node-v8", version: "v24.18.0", v8_heap_limit_bytes: 1 }
|
|
: {
|
|
id: "bun",
|
|
version: "1.4.2",
|
|
node_compatibility_version: "24.18.0",
|
|
v8_heap_limit_bytes: null,
|
|
process_memory_limit_bytes: null,
|
|
},
|
|
});
|
|
if (response.ok) throw new Error("expected runtime mismatch");
|
|
expect(response.error._tag).toBe("AnalysisOutputError");
|
|
if (actual.value.runtime.id !== "bun") return;
|
|
// A Bun codec must report exactly the process limit its launcher enforced.
|
|
const limit = await replay({
|
|
...actual.value,
|
|
runtime: {
|
|
...actual.value.runtime,
|
|
process_memory_limit_bytes:
|
|
SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES === null ? 4096 : null,
|
|
},
|
|
});
|
|
if (limit.ok) throw new Error("expected process limit mismatch");
|
|
expect(limit.error.message).toContain("process memory limit");
|
|
});
|
|
it.each([true, false])(
|
|
"classifies a codec exit by the job memory notification (reached: %s)",
|
|
async (reached) => {
|
|
const response = await realDecoder({
|
|
launcher: async ({ memoryLimitBytes, ...options }) => {
|
|
expect(memoryLimitBytes).toBe(
|
|
SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES ?? undefined,
|
|
);
|
|
// Without a job limit any artifact runs the child; its handle reports
|
|
// the notification an owned Windows job records before exit.
|
|
const spawned = await spawnOwnedProviderProcess({
|
|
...options,
|
|
arguments: [
|
|
"-e",
|
|
'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;
|
|
},
|
|
}).trace(input);
|
|
if (response.ok) throw new Error("expected codec failure");
|
|
expect(response.error).toMatchObject(
|
|
reached
|
|
? {
|
|
_tag: "AnalysisResourceConstraintError",
|
|
resource: "memory",
|
|
reason: expect.stringContaining("process memory limit"),
|
|
reportedLimits: {
|
|
codec_process_memory_limit_bytes:
|
|
SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES,
|
|
},
|
|
capturedOutput: { stderr: "panic: out of memory" },
|
|
}
|
|
: { diagnostics: { exit_code: 3, stderr: "panic: out of memory" } },
|
|
);
|
|
},
|
|
);
|
|
it("removes the private request when launching the codec fails", async () => {
|
|
let path: string | undefined;
|
|
const response = await realDecoder({
|
|
createRuntime: async () => {
|
|
const root = await PrivateRuntimeRoot.create();
|
|
path = root.path;
|
|
return root;
|
|
},
|
|
launcher: () => Promise.reject(new Error("observed launch failure")),
|
|
}).trace(input);
|
|
if (response.ok) throw new Error("expected launch failure");
|
|
expect(response.error).toMatchObject({
|
|
diagnostics: { error_message: "observed launch failure" },
|
|
});
|
|
if (path === undefined) throw new Error("runtime not acquired");
|
|
await expect(access(path)).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|