Files
rea/src/javascript/sourceMaps/SourceMapCommand.ts
T
xeropresenceandmorluto 66c21572de fix(bun): report the real runtime and enforce worker memory budgets on Windows (#1730)
* 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>
2026-10-11 19:20:00 +08:00

312 lines
11 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import type { ExecutionOptions } from "../../application/AnalysisProvider.js";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisOutputError,
AnalysisResourceConstraintError,
AnalysisTimeoutError,
} from "../../domain/analysisErrorCore.js";
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import { err, ok, type Result } from "../../domain/result.js";
import { WEB_SOURCE_MAP_LIMITS } from "../../domain/webSourceLocation.js";
import {
PrivateRuntimeRoot,
PrivateRuntimeRootUnavailableError,
} from "../../process/PrivateRuntimeRoot.js";
import { ProviderStartupDeadline } from "../../process/ProviderDeadline.js";
import { javaScriptWorkerMemoryLimitBytes } from "../../process/JavaScriptWorkerMemoryLimit.js";
import { cleanupOwnedProcessGroup } from "../../process/ProcessOwnership.js";
import {
ProviderProcessSupervisor,
spawnOwnedProviderProcess,
type OwnedProviderProcessSpawnOptions,
type SpawnedOwnedProviderProcess,
} from "../../process/ProviderProcess.js";
import { prepareProcessOwnershipInspection } from "../../process/ProcessOwnershipObservation.js";
const OPERATION = "trace_web_source_location";
const PROVIDER = "source-map-decoder";
/** Process commit limit enforced on the codec; null where V8 heap flags apply. */
export const SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES =
javaScriptWorkerMemoryLimitBytes(WEB_SOURCE_MAP_LIMITS.codecHeapMiB);
/** Launcher seam retains the actual codec protocol and ownership supervisor. */
export type SourceMapCodecLauncher = (
options: OwnedProviderProcessSpawnOptions,
) => Promise<SpawnedOwnedProviderProcess>;
/** Runtime implementations establish privacy before returning an owned directory. */
export interface SourceMapCodecRuntime {
readonly path: string;
close(): Promise<void>;
}
/** Replaceable acquisition boundaries for an owned codec operation. */
export interface SourceMapDecoderDependencies {
readonly launcher?: SourceMapCodecLauncher;
readonly createRuntime?: () => Promise<SourceMapCodecRuntime>;
readonly environment?: Readonly<NodeJS.ProcessEnv>;
/** Prepare host ownership inspection outside the codec execution deadline. */
readonly prepareOwnership?: (signal?: AbortSignal) => Promise<void>;
}
export interface SourceMapCleanupOwner {
close(
previousError: string | null,
): Promise<ProviderCleanupError | undefined>;
}
export class SourceMapCleanupFailure extends ProviderCleanupError {
constructor(
readonly cleanupOwner: SourceMapCleanupOwner,
resources: readonly string[],
diagnostics: Readonly<Record<string, JsonValue>>,
) {
super(PROVIDER, resources, diagnostics, { operation: OPERATION });
}
}
/** Run a trusted codec command and release its process/root before returning output. */
export const runSourceMapCommand = async (
request: string,
path: string,
options: ExecutionOptions | undefined,
dependencies: SourceMapDecoderDependencies,
): Promise<Result<string, AnalysisError>> => {
try {
await (dependencies.prepareOwnership ?? prepareProcessOwnershipInspection)(
options?.signal,
);
} catch (cause: unknown) {
return err(
options?.signal?.aborted === true
? new AnalysisCancelledError(OPERATION)
: new ProviderAdapterError(PROVIDER, OPERATION, {
cause,
diagnostics: {
map_path: path,
error_message: message(cause),
phase: "process_ownership_preparation",
},
}),
);
}
const deadline = new ProviderStartupDeadline(
WEB_SOURCE_MAP_LIMITS.decodeTimeoutMs,
options?.signal,
);
const command = new OwnedCodec(path, dependencies, deadline);
let result: Result<string, AnalysisError>;
try {
await command.start(request);
result = ok(await command.collect());
} catch (cause: unknown) {
result = err(command.failure(cause));
} finally {
deadline.dispose();
}
const cleanup = await command.close(result.ok ? null : result.error.message);
if (cleanup !== undefined) return err(cleanup);
return result.ok && options?.signal?.aborted
? err(new AnalysisCancelledError(OPERATION))
: result;
};
class OwnedCodec implements SourceMapCleanupOwner {
readonly #runId = `rea-source-map-${randomUUID()}`;
#runtime: SourceMapCodecRuntime | undefined;
#supervisor: ProviderProcessSupervisor | undefined;
#oversized = false;
#processError: string | undefined;
constructor(
readonly path: string,
readonly dependencies: SourceMapDecoderDependencies,
readonly deadline: ProviderStartupDeadline,
) {}
async start(request: string): Promise<void> {
this.check();
this.#runtime = await (
this.dependencies.createRuntime ??
(() => PrivateRuntimeRoot.create({ prefix: "rea-source-map-" }))
)();
this.check();
const requestPath = join(this.#runtime.path, "request.json");
await writeFile(requestPath, request, {
flag: "wx",
mode: 0o600,
signal: this.deadline.signal,
});
this.check();
const environment: NodeJS.ProcessEnv = {
...(this.dependencies.environment ?? process.env),
UV_THREADPOOL_SIZE: "1",
};
delete environment.NODE_OPTIONS;
const spawned = await (
this.dependencies.launcher ?? spawnOwnedProviderProcess
)({
command: process.execPath,
arguments: [
`--max-old-space-size=${String(WEB_SOURCE_MAP_LIMITS.codecHeapMiB)}`,
"--max-semi-space-size=8",
"--v8-pool-size=1",
"--single-threaded",
"--stack_size=2048",
fileURLToPath(new URL("./SourceMapCodecProcess.js", import.meta.url)),
requestPath,
],
runId: this.#runId,
cwd: this.#runtime.path,
hostEnvironment: environment,
...(SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES === null
? {}
: { memoryLimitBytes: SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES }),
signal: this.deadline.signal,
});
this.#supervisor = new ProviderProcessSupervisor(
{
...spawned,
ownsProcessLifetime: true,
cleanup:
spawned.cleanup ??
(() => cleanupOwnedProcessGroup(spawned.ownership)),
},
{
onDiagnostic: (event) => {
if (event.type === "error") this.#processError = event.message;
if (
event.type === "output" &&
event.totalBytes >
(event.stream === "stdout"
? WEB_SOURCE_MAP_LIMITS.outputBytes
: 1024 * 1024)
)
this.#oversized = true;
},
},
);
}
async collect(): Promise<string> {
const supervisor = this.#supervisor;
if (supervisor === undefined)
throw new Error("Codec process was not acquired");
while (!(await supervisor.waitForOutputClose(50))) this.check();
this.check();
const snapshot = supervisor.snapshot();
// No partial codec result exists; a reached limit fails the whole trace.
if (snapshot.memoryLimitReached === true)
throw new AnalysisResourceConstraintError(
OPERATION,
"memory",
`Source-map codec reached its ${SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES === null ? "" : `${String(SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES)}-byte `}process memory limit for ${this.path}; no partial evidence was returned.`,
{
codec_process_memory_limit_bytes: SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES,
},
{
capturedOutput: {
stdout: "",
stderr: snapshot.stderr.text,
truncated: snapshot.diagnosticTruncated ?? false,
},
},
);
if (snapshot.exitCode !== 0 || snapshot.signal !== null)
throw new ProviderAdapterError(PROVIDER, OPERATION, {
diagnostics: {
map_path: this.path,
exit_code: snapshot.exitCode ?? null,
signal: snapshot.signal ?? null,
stderr: snapshot.stderr.text,
},
});
return snapshot.stdout.text;
}
check(): void {
if (this.deadline.interruption === "cancelled")
throw new AnalysisCancelledError(OPERATION);
if (this.deadline.interruption === "timeout")
throw new AnalysisTimeoutError(
OPERATION,
WEB_SOURCE_MAP_LIMITS.decodeTimeoutMs,
);
if (this.#oversized)
throw new AnalysisOutputError(
OPERATION,
"Codec output exceeds its complete-evidence or diagnostic budget.",
);
if (this.#processError !== undefined)
throw new ProviderAdapterError(PROVIDER, OPERATION, {
diagnostics: { map_path: this.path, error_message: this.#processError },
});
}
failure(cause: unknown): AnalysisError {
if (this.deadline.interruption === "cancelled")
return new AnalysisCancelledError(OPERATION);
if (this.deadline.interruption === "timeout")
return new AnalysisTimeoutError(
OPERATION,
WEB_SOURCE_MAP_LIMITS.decodeTimeoutMs,
);
if (cause instanceof AnalysisError) return cause;
return cause instanceof PrivateRuntimeRootUnavailableError
? new AnalysisCapabilityUnavailableError(
PROVIDER,
OPERATION,
cause.message,
{ cause },
)
: new ProviderAdapterError(PROVIDER, OPERATION, {
cause,
diagnostics: { map_path: this.path, error_message: message(cause) },
});
}
async close(
previousError: string | null,
): Promise<ProviderCleanupError | undefined> {
const failures: string[] = [];
let processReleased = this.#supervisor === undefined;
if (this.#supervisor !== undefined) {
const stopped = await this.#supervisor.stop();
if (stopped.status === "incomplete") failures.push(stopped.reason);
else {
processReleased = true;
this.#supervisor = undefined;
}
}
if (processReleased && this.#runtime !== undefined) {
try {
await this.#runtime.close();
this.#runtime = undefined;
} catch (cause: unknown) {
failures.push(message(cause));
}
}
return failures.length === 0
? undefined
: new SourceMapCleanupFailure(
this,
[
this.#runId,
...(this.#runtime === undefined ? [] : [this.#runtime.path]),
],
{
map_path: this.path,
cleanup_failures: failures,
previous_error: previousError,
codec_pid: this.#supervisor?.launch.process.pid ?? null,
},
);
}
}
const message = (cause: unknown): string =>
cause instanceof Error ? cause.message : String(cause);