513 lines
18 KiB
TypeScript
513 lines
18 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,
|
|
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 { noRuntimeOverrideSentence } from "../../domain/browserDeadline.js";
|
|
import {
|
|
DEFAULT_WEB_SOURCE_MAP_DECODE_TIMEOUT_MS,
|
|
WEB_SOURCE_MAP_LIMITS,
|
|
webSourceMapDecodeTimeoutRecovery,
|
|
} from "../../domain/webSourceLocation.js";
|
|
import { PrivateRuntimeRoot } from "../../process/PrivateRuntimeRoot.js";
|
|
import {
|
|
RuntimeControlUnavailableError,
|
|
runtimeControlCapabilityError,
|
|
} from "../../process/RuntimeControlUnavailableError.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 ProviderProcessExitObservation,
|
|
type ProviderProcessSnapshot,
|
|
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>;
|
|
}
|
|
|
|
type CodecPhysicalObservation =
|
|
| {
|
|
readonly kind: "exit";
|
|
readonly exit: ProviderProcessExitObservation;
|
|
}
|
|
| {
|
|
readonly kind: "process-error";
|
|
readonly message: string;
|
|
readonly cause: Error;
|
|
}
|
|
| { readonly kind: "output-limit" };
|
|
type CodecOutcome =
|
|
| {
|
|
readonly kind: "physical";
|
|
readonly observation: CodecPhysicalObservation;
|
|
}
|
|
| { readonly kind: "cancelled"; readonly callerReason: unknown }
|
|
| { readonly kind: "timeout" };
|
|
|
|
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,
|
|
decodeTimeoutMs: number = DEFAULT_WEB_SOURCE_MAP_DECODE_TIMEOUT_MS,
|
|
): Promise<Result<string, AnalysisError>> => {
|
|
try {
|
|
await (dependencies.prepareOwnership ?? prepareProcessOwnershipInspection)(
|
|
options?.signal,
|
|
);
|
|
} catch (cause: unknown) {
|
|
return err(
|
|
options?.signal?.aborted === true
|
|
? new AnalysisCancelledError(OPERATION, {
|
|
cause: options.signal.reason,
|
|
})
|
|
: cause instanceof AnalysisError
|
|
? cause
|
|
: new ProviderAdapterError(PROVIDER, OPERATION, {
|
|
cause,
|
|
diagnostics: {
|
|
map_path: path,
|
|
error_message: message(cause),
|
|
phase: "process_ownership_preparation",
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
const deadline = new ProviderStartupDeadline(
|
|
decodeTimeoutMs,
|
|
options?.signal,
|
|
);
|
|
const command = new OwnedCodec(path, dependencies, deadline, decodeTimeoutMs);
|
|
let result: Result<string, AnalysisError>;
|
|
try {
|
|
await command.start(request);
|
|
result = ok(await command.collect());
|
|
} catch (cause: unknown) {
|
|
result = err(command.failure(cause));
|
|
} finally {
|
|
command.dispose();
|
|
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, { cause: options.signal.reason }),
|
|
)
|
|
: result;
|
|
};
|
|
|
|
class OwnedCodec implements SourceMapCleanupOwner {
|
|
readonly #runId = `rea-source-map-${randomUUID()}`;
|
|
#runtime: SourceMapCodecRuntime | undefined;
|
|
#supervisor: ProviderProcessSupervisor | undefined;
|
|
#cleanupAnchor: SourceMapCleanupFailure | undefined;
|
|
#outcome: CodecOutcome | undefined;
|
|
#drainStop: CodecPhysicalObservation | undefined;
|
|
readonly #onInterruption: () => void;
|
|
constructor(
|
|
readonly path: string,
|
|
readonly dependencies: SourceMapDecoderDependencies,
|
|
readonly deadline: ProviderStartupDeadline,
|
|
readonly decodeTimeoutMs: number,
|
|
) {
|
|
this.#onInterruption = () => this.#observeInterruption();
|
|
if (deadline.signal.aborted) this.#observeInterruption();
|
|
else
|
|
deadline.signal.addEventListener("abort", this.#onInterruption, {
|
|
once: true,
|
|
});
|
|
}
|
|
|
|
/** Stop recording event order after an outcome has been selected. */
|
|
dispose(): void {
|
|
this.deadline.signal.removeEventListener("abort", this.#onInterruption);
|
|
}
|
|
|
|
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)),
|
|
},
|
|
{
|
|
maxDiagnosticBytes: WEB_SOURCE_MAP_LIMITS.outputBytes + 1024 * 1024,
|
|
onDiagnostic: (event) => {
|
|
if (
|
|
event.type === "exit" &&
|
|
(event.code !== 0 || event.signal !== null)
|
|
)
|
|
this.#recordPhysicalFailure({
|
|
kind: "exit",
|
|
exit: { code: event.code, signal: event.signal },
|
|
});
|
|
if (event.type === "error") {
|
|
this.#recordPhysicalFailure({
|
|
kind: "process-error",
|
|
message: event.message,
|
|
cause: event.cause ?? new Error(event.message),
|
|
});
|
|
}
|
|
if (
|
|
event.type === "output" &&
|
|
(event.truncated === true ||
|
|
event.totalBytes >
|
|
(event.stream === "stdout"
|
|
? WEB_SOURCE_MAP_LIMITS.outputBytes
|
|
: 1024 * 1024))
|
|
) {
|
|
this.#recordPhysicalFailure({ kind: "output-limit" });
|
|
}
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
async collect(): Promise<string> {
|
|
const supervisor = this.#supervisor;
|
|
if (supervisor === undefined)
|
|
throw new Error("Codec process was not acquired");
|
|
while (!(await supervisor.waitForOutputClose(50))) {
|
|
const outcome = this.#outcome;
|
|
if (
|
|
outcome?.kind !== "physical" ||
|
|
outcome.observation.kind !== "exit" ||
|
|
this.deadline.signal.aborted ||
|
|
this.#drainStop !== undefined
|
|
)
|
|
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 this.#memoryLimitFailure(snapshot);
|
|
return snapshot.stdout.text;
|
|
}
|
|
|
|
check(): void {
|
|
this.#observeInterruption();
|
|
const outcome = this.#outcome;
|
|
if (outcome?.kind === "physical")
|
|
throw this.#physicalFailure(outcome.observation);
|
|
if (outcome?.kind === "cancelled")
|
|
throw new AnalysisCancelledError(OPERATION, {
|
|
cause: outcome.callerReason,
|
|
});
|
|
if (outcome?.kind === "timeout")
|
|
throw new AnalysisTimeoutError(
|
|
OPERATION,
|
|
this.decodeTimeoutMs,
|
|
webSourceMapDecodeTimeoutRecovery(this.decodeTimeoutMs),
|
|
);
|
|
}
|
|
|
|
failure(cause: unknown): AnalysisError {
|
|
this.#observeInterruption();
|
|
const outcome = this.#outcome;
|
|
if (outcome?.kind === "physical")
|
|
return this.#physicalFailure(outcome.observation);
|
|
if (outcome?.kind === "cancelled")
|
|
return new AnalysisCancelledError(OPERATION, {
|
|
cause: outcome.callerReason,
|
|
});
|
|
if (outcome?.kind === "timeout")
|
|
return new AnalysisTimeoutError(
|
|
OPERATION,
|
|
this.decodeTimeoutMs,
|
|
webSourceMapDecodeTimeoutRecovery(this.decodeTimeoutMs),
|
|
);
|
|
if (cause instanceof AnalysisError) return cause;
|
|
return cause instanceof RuntimeControlUnavailableError
|
|
? runtimeControlCapabilityError(PROVIDER, OPERATION, cause)
|
|
: new ProviderAdapterError(PROVIDER, OPERATION, {
|
|
cause,
|
|
diagnostics: { map_path: this.path, error_message: message(cause) },
|
|
});
|
|
}
|
|
|
|
#observeInterruption(): void {
|
|
if (this.#outcome !== undefined) return;
|
|
if (!this.deadline.signal.aborted) return;
|
|
const interruption = this.deadline.interruption;
|
|
if (interruption === undefined) return;
|
|
this.#outcome =
|
|
interruption === "cancelled"
|
|
? { kind: "cancelled", callerReason: this.deadline.signal.reason }
|
|
: { kind: "timeout" };
|
|
}
|
|
|
|
#recordPhysicalFailure(observation: CodecPhysicalObservation): void {
|
|
this.#observeInterruption();
|
|
if (this.#outcome === undefined)
|
|
this.#outcome = { kind: "physical", observation };
|
|
else if (observation.kind !== "exit" && this.#drainStop === undefined)
|
|
this.#drainStop = observation;
|
|
}
|
|
|
|
#physicalFailure(observation: CodecPhysicalObservation): AnalysisError {
|
|
const snapshot = this.#supervisor?.snapshot();
|
|
if (observation.kind === "exit") {
|
|
if (snapshot?.memoryLimitReached === true)
|
|
return this.#memoryLimitFailure(snapshot);
|
|
return this.#exitFailure(observation.exit, snapshot);
|
|
}
|
|
if (observation.kind === "process-error")
|
|
return this.#processErrorFailure(observation, snapshot);
|
|
return this.#outputLimitFailure(snapshot);
|
|
}
|
|
|
|
#exitFailure(
|
|
exit: ProviderProcessExitObservation,
|
|
snapshot: ProviderProcessSnapshot | undefined,
|
|
): AnalysisError {
|
|
const outputComplete = this.#supervisor?.observedOutputClose() ?? false;
|
|
const capturedOutput = this.#capturedOutput(snapshot, exit, outputComplete);
|
|
return new ProviderAdapterError(PROVIDER, OPERATION, {
|
|
diagnostics: {
|
|
map_path: this.path,
|
|
exit_code: exit.code,
|
|
signal: exit.signal,
|
|
output_complete: outputComplete,
|
|
stderr: snapshot?.stderr.text ?? "",
|
|
...(this.#drainStop?.kind === "output-limit"
|
|
? { output_limit_reached: true }
|
|
: {}),
|
|
...(this.#drainStop?.kind === "process-error"
|
|
? { drain_error_message: this.#drainStop.message }
|
|
: {}),
|
|
},
|
|
...(capturedOutput === undefined ? {} : { capturedOutput }),
|
|
});
|
|
}
|
|
|
|
#processErrorFailure(
|
|
observation: Extract<CodecPhysicalObservation, { kind: "process-error" }>,
|
|
snapshot: ProviderProcessSnapshot | undefined,
|
|
): AnalysisError {
|
|
const outputComplete = this.#supervisor?.observedOutputClose() ?? false;
|
|
const capturedOutput = this.#capturedOutput(
|
|
snapshot,
|
|
undefined,
|
|
outputComplete,
|
|
);
|
|
return new ProviderAdapterError(PROVIDER, OPERATION, {
|
|
cause: observation.cause,
|
|
diagnostics: {
|
|
map_path: this.path,
|
|
error_message: observation.message,
|
|
output_complete: outputComplete,
|
|
},
|
|
...(capturedOutput === undefined ? {} : { capturedOutput }),
|
|
});
|
|
}
|
|
|
|
#outputLimitFailure(
|
|
snapshot: ProviderProcessSnapshot | undefined,
|
|
): AnalysisError {
|
|
const outputComplete = this.#supervisor?.observedOutputClose() ?? false;
|
|
const capturedOutput = this.#capturedOutput(
|
|
snapshot,
|
|
undefined,
|
|
outputComplete,
|
|
);
|
|
return new AnalysisOutputError(
|
|
OPERATION,
|
|
"Codec output exceeds its complete-evidence or diagnostic budget.",
|
|
{
|
|
capturedOutput: {
|
|
stdout: capturedOutput?.stdout ?? "",
|
|
stderr: capturedOutput?.stderr ?? "",
|
|
stdout_bytes: capturedOutput?.stdout_bytes ?? 0,
|
|
stderr_bytes: capturedOutput?.stderr_bytes ?? 0,
|
|
truncated: true,
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
#capturedOutput(
|
|
snapshot: ProviderProcessSnapshot | undefined,
|
|
exit: ProviderProcessExitObservation | undefined,
|
|
outputComplete: boolean,
|
|
) {
|
|
if (snapshot === undefined) return undefined;
|
|
return {
|
|
stdout: snapshot.stdout.text,
|
|
stderr: snapshot.stderr.text,
|
|
truncated: snapshot.diagnosticTruncated === true || !outputComplete,
|
|
stdout_bytes: snapshot.stdout.observedBytes,
|
|
stderr_bytes: snapshot.stderr.observedBytes,
|
|
...(exit === undefined
|
|
? {}
|
|
: { exit_code: exit.code, signal: exit.signal }),
|
|
};
|
|
}
|
|
|
|
#memoryLimitFailure(snapshot: ProviderProcessSnapshot): AnalysisError {
|
|
return 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. ${noRuntimeOverrideSentence(`The ${String(WEB_SOURCE_MAP_LIMITS.codecHeapMiB)} MiB codec heap stays fixed.`, "decode_timeout_ms")}`,
|
|
{
|
|
codec_process_memory_limit_bytes: SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES,
|
|
},
|
|
{
|
|
capturedOutput: {
|
|
stdout: "",
|
|
stderr: snapshot.stderr.text,
|
|
truncated:
|
|
snapshot.diagnosticTruncated === true ||
|
|
!(this.#supervisor?.observedOutputClose() ?? false),
|
|
},
|
|
remediationAction: noRuntimeOverrideSentence(
|
|
`The ${String(WEB_SOURCE_MAP_LIMITS.codecHeapMiB)} MiB codec heap, ${String(WEB_SOURCE_MAP_LIMITS.mapBytes)}-byte map budget, and ${String(WEB_SOURCE_MAP_LIMITS.outputBytes)}-byte reply budget stay fixed.`,
|
|
"decode_timeout_ms",
|
|
),
|
|
},
|
|
);
|
|
}
|
|
|
|
/** Current ownership, independent of historical cleanup diagnostics. */
|
|
get cleanupResources(): readonly string[] {
|
|
return [
|
|
...(this.#supervisor === undefined ? [] : [this.#runId]),
|
|
...(this.#runtime === undefined ? [] : [this.#runtime.path]),
|
|
];
|
|
}
|
|
|
|
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));
|
|
}
|
|
}
|
|
if (failures.length === 0) {
|
|
this.#cleanupAnchor?.resolveCleanup();
|
|
this.#cleanupAnchor = undefined;
|
|
return undefined;
|
|
}
|
|
const failure = new SourceMapCleanupFailure(this, this.cleanupResources, {
|
|
map_path: this.path,
|
|
cleanup_failures: failures,
|
|
previous_error: previousError,
|
|
codec_pid: this.#supervisor?.launch.process.pid ?? null,
|
|
}).bindCleanupResourceOwner(this);
|
|
if (this.#cleanupAnchor === undefined) this.#cleanupAnchor = failure;
|
|
else failure.bindCleanupDependencies([this.#cleanupAnchor], this);
|
|
return failure;
|
|
}
|
|
}
|
|
const message = (cause: unknown): string =>
|
|
cause instanceof Error ? cause.message : String(cause);
|