mirror of
https://github.com/morluto/rea.git
synced 2026-10-29 02:45:58 +00:00
fix(source-maps): preserve failure priority and cancellation provenance
This commit is contained in:
1 parent
cc73587ec4
commit
be20ac3740
6 files changed
+627
-59
No files matched your search
@@ -0,0 +1,278 @@
|
||||
import { access } from "node:fs/promises";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisResourceConstraintError,
|
||||
AnalysisTimeoutError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { PrivateRuntimeRoot } from "../../process/PrivateRuntimeRoot.js";
|
||||
import {
|
||||
ProviderProcessSupervisor,
|
||||
spawnOwnedProviderProcess,
|
||||
} from "../../process/ProviderProcess.js";
|
||||
import { WEB_SOURCE_MAP_LIMITS } from "../../domain/webSourceLocation.js";
|
||||
import {
|
||||
runSourceMapCommand,
|
||||
SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES,
|
||||
} from "./SourceMapCommand.js";
|
||||
|
||||
const runChild = (
|
||||
signal: AbortSignal | undefined,
|
||||
script: string,
|
||||
onRuntime?: (path: string) => void,
|
||||
onLaunch?: () => void,
|
||||
) =>
|
||||
runSourceMapCommand(
|
||||
"{}",
|
||||
"/probe/source.map",
|
||||
signal === undefined ? undefined : { signal },
|
||||
{
|
||||
prepareOwnership: async () => {},
|
||||
createRuntime: async () => {
|
||||
const runtime = await PrivateRuntimeRoot.create({
|
||||
prefix: "rea-source-map-command-test-",
|
||||
});
|
||||
onRuntime?.(runtime.path);
|
||||
return runtime;
|
||||
},
|
||||
launcher: async (options) => {
|
||||
const child = await spawnOwnedProviderProcess({
|
||||
...options,
|
||||
arguments: ["-e", script],
|
||||
});
|
||||
onLaunch?.();
|
||||
return child;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const exitsAfterHoldingOutput =
|
||||
'const {spawn}=require("node:child_process"); spawn(process.execPath,["-e","setTimeout(()=>{},400)"],{stdio:["ignore","inherit","inherit"]}); process.stderr.write("physical exit 7"); process.exit(7)';
|
||||
const exitsThenDescendantExceedsStderrBudget =
|
||||
'const {spawn}=require("node:child_process"); spawn(process.execPath,["-e",`setTimeout(()=>{process.stderr.write("x".repeat(1024*1024+64)); setInterval(()=>{},1000)},25)`],{stdio:["ignore","inherit","inherit"]}); process.stderr.write("physical exit 7"); process.exit(7)';
|
||||
|
||||
it("preserves the exact object reason for an already-aborted caller", async () => {
|
||||
const reason = { caller: "opaque source-map request" };
|
||||
const controller = new AbortController();
|
||||
controller.abort(reason);
|
||||
const result = await runSourceMapCommand(
|
||||
"{}",
|
||||
"/probe/source.map",
|
||||
{ signal: controller.signal },
|
||||
{
|
||||
prepareOwnership: async () => {},
|
||||
launcher: async () => {
|
||||
throw new Error("must not launch");
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("expected cancellation");
|
||||
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(result.error.cause).toBe(reason);
|
||||
});
|
||||
|
||||
it("preserves exact caller reason when cancellation precedes process exit", async () => {
|
||||
const reason = { caller: "active source-map cancellation" };
|
||||
const controller = new AbortController();
|
||||
let runtimePath: string | undefined;
|
||||
const result = await runChild(
|
||||
controller.signal,
|
||||
'process.stdout.write("ready\\n"); setInterval(()=>{},1000)',
|
||||
(path) => {
|
||||
runtimePath = path;
|
||||
},
|
||||
() => setTimeout(() => controller.abort(reason), 0),
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("expected cancellation");
|
||||
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(result.error.cause).toBe(reason);
|
||||
if (runtimePath === undefined) throw new Error("runtime was not acquired");
|
||||
await expect(access(runtimePath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("preserves an observed nonzero exit before caller cancellation during output drain", async () => {
|
||||
const reason = { caller: "later than owned exit" };
|
||||
const controller = new AbortController();
|
||||
let runtimePath: string | undefined;
|
||||
let observedBeforeAbort:
|
||||
| { readonly code: number | null; readonly signal: NodeJS.Signals | null }
|
||||
| undefined;
|
||||
const originalWait = ProviderProcessSupervisor.prototype.waitForOutputClose;
|
||||
const waitSpy = vi
|
||||
.spyOn(ProviderProcessSupervisor.prototype, "waitForOutputClose")
|
||||
.mockImplementation(async function (
|
||||
this: ProviderProcessSupervisor,
|
||||
timeoutMs,
|
||||
signal,
|
||||
) {
|
||||
const exit = this.observedExit();
|
||||
if (exit?.code === 7 && !controller.signal.aborted) {
|
||||
observedBeforeAbort = exit;
|
||||
controller.abort(reason);
|
||||
return false;
|
||||
}
|
||||
return originalWait.call(this, timeoutMs, signal);
|
||||
});
|
||||
let result;
|
||||
try {
|
||||
result = await runChild(
|
||||
controller.signal,
|
||||
exitsAfterHoldingOutput,
|
||||
(path) => {
|
||||
runtimePath = path;
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
waitSpy.mockRestore();
|
||||
}
|
||||
expect(observedBeforeAbort).toEqual({ code: 7, signal: null });
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("expected physical failure");
|
||||
expect(result.error._tag).toBe("ProviderAdapterError");
|
||||
expect(result.error.diagnostics).toMatchObject({
|
||||
exit_code: 7,
|
||||
output_complete: false,
|
||||
});
|
||||
expect(result.error.capturedOutput?.truncated).toBe(true);
|
||||
if (runtimePath === undefined) throw new Error("runtime was not acquired");
|
||||
await expect(access(runtimePath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("classifies the same nonzero exit without cancellation", async () => {
|
||||
const result = await runChild(undefined, exitsAfterHoldingOutput);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("expected physical failure");
|
||||
expect(result.error).toMatchObject({
|
||||
_tag: "ProviderAdapterError",
|
||||
diagnostics: {
|
||||
exit_code: 7,
|
||||
stderr: "physical exit 7",
|
||||
output_complete: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("stops a held drain after late output exceeds budget but keeps the exit primary", async () => {
|
||||
const startedAt = performance.now();
|
||||
const result = await runChild(
|
||||
undefined,
|
||||
exitsThenDescendantExceedsStderrBudget,
|
||||
);
|
||||
const elapsedMs = performance.now() - startedAt;
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("expected physical failure");
|
||||
expect(result.error._tag).toBe("ProviderAdapterError");
|
||||
expect(result.error.diagnostics).toMatchObject({
|
||||
exit_code: 7,
|
||||
output_limit_reached: true,
|
||||
output_complete: false,
|
||||
});
|
||||
expect(result.error.diagnostics?.stderr).toContain("physical exit 7");
|
||||
expect(result.error.capturedOutput?.truncated).toBe(true);
|
||||
expect(elapsedMs).toBeLessThan(5_000);
|
||||
});
|
||||
|
||||
it("preserves an observed process-memory limit over the codec exit status", async () => {
|
||||
const controller = new AbortController();
|
||||
const reason = { caller: "after memory-limit exit" };
|
||||
let observedBeforeAbort:
|
||||
| { readonly code: number | null; readonly signal: NodeJS.Signals | null }
|
||||
| undefined;
|
||||
const originalWait = ProviderProcessSupervisor.prototype.waitForOutputClose;
|
||||
const waitSpy = vi
|
||||
.spyOn(ProviderProcessSupervisor.prototype, "waitForOutputClose")
|
||||
.mockImplementation(async function (
|
||||
this: ProviderProcessSupervisor,
|
||||
timeoutMs,
|
||||
signal,
|
||||
) {
|
||||
const exit = this.observedExit();
|
||||
if (exit?.code === 3 && !controller.signal.aborted) {
|
||||
observedBeforeAbort = exit;
|
||||
controller.abort(reason);
|
||||
return false;
|
||||
}
|
||||
return originalWait.call(this, timeoutMs, signal);
|
||||
});
|
||||
let result;
|
||||
try {
|
||||
result = await runSourceMapCommand(
|
||||
"{}",
|
||||
"/probe/source.map",
|
||||
{ signal: controller.signal },
|
||||
{
|
||||
prepareOwnership: async () => {},
|
||||
launcher: async (options) => {
|
||||
const spawned = await spawnOwnedProviderProcess({
|
||||
...options,
|
||||
arguments: [
|
||||
"-e",
|
||||
'const {spawn}=require("node:child_process"); spawn(process.execPath,["-e","setTimeout(()=>{},400)"],{stdio:["ignore","inherit","inherit"]}); process.stderr.write("memory limit reached"); process.exit(3)',
|
||||
],
|
||||
});
|
||||
let exited = false;
|
||||
spawned.process.once("exit", () => {
|
||||
exited = true;
|
||||
});
|
||||
Object.defineProperty(spawned.process, "memoryLimitReached", {
|
||||
get: () => exited,
|
||||
});
|
||||
return spawned;
|
||||
},
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
waitSpy.mockRestore();
|
||||
}
|
||||
expect(observedBeforeAbort).toEqual({ code: 3, signal: null });
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("expected memory-limit failure");
|
||||
expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError);
|
||||
expect(result.error).toMatchObject({
|
||||
resource: "memory",
|
||||
reportedLimits: {
|
||||
codec_process_memory_limit_bytes: SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES,
|
||||
},
|
||||
capturedOutput: { stderr: "memory limit reached" },
|
||||
});
|
||||
expect(result.error.capturedOutput?.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a timeout distinct from caller cancellation", async () => {
|
||||
vi.useFakeTimers({
|
||||
toFake: ["setTimeout", "clearTimeout", "performance"],
|
||||
});
|
||||
let runtimePath: string | undefined;
|
||||
try {
|
||||
const result = await runSourceMapCommand(
|
||||
"{}",
|
||||
"/probe/source.map",
|
||||
undefined,
|
||||
{
|
||||
prepareOwnership: async () => {},
|
||||
createRuntime: async () => {
|
||||
const runtime = await PrivateRuntimeRoot.create({
|
||||
prefix: "rea-source-map-timeout-test-",
|
||||
});
|
||||
runtimePath = runtime.path;
|
||||
return runtime;
|
||||
},
|
||||
launcher: async () => {
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
WEB_SOURCE_MAP_LIMITS.decodeTimeoutMs + 1,
|
||||
);
|
||||
throw new Error("launcher settled after deadline");
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("expected timeout");
|
||||
expect(result.error).toBeInstanceOf(AnalysisTimeoutError);
|
||||
if (runtimePath === undefined) throw new Error("runtime was not acquired");
|
||||
await expect(access(runtimePath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
ProviderProcessSupervisor,
|
||||
spawnOwnedProviderProcess,
|
||||
type OwnedProviderProcessSpawnOptions,
|
||||
type ProviderProcessExitObservation,
|
||||
type ProviderProcessSnapshot,
|
||||
type SpawnedOwnedProviderProcess,
|
||||
} from "../../process/ProviderProcess.js";
|
||||
import { prepareProcessOwnershipInspection } from "../../process/ProcessOwnershipObservation.js";
|
||||
@@ -58,6 +60,25 @@ export interface SourceMapCleanupOwner {
|
||||
): 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,
|
||||
@@ -82,7 +103,9 @@ export const runSourceMapCommand = async (
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
options?.signal?.aborted === true
|
||||
? new AnalysisCancelledError(OPERATION)
|
||||
? new AnalysisCancelledError(OPERATION, {
|
||||
cause: options.signal.reason,
|
||||
})
|
||||
: new ProviderAdapterError(PROVIDER, OPERATION, {
|
||||
cause,
|
||||
diagnostics: {
|
||||
@@ -105,12 +128,15 @@ export const runSourceMapCommand = async (
|
||||
} 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))
|
||||
? err(
|
||||
new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }),
|
||||
)
|
||||
: result;
|
||||
};
|
||||
|
||||
@@ -118,13 +144,26 @@ class OwnedCodec implements SourceMapCleanupOwner {
|
||||
readonly #runId = `rea-source-map-${randomUUID()}`;
|
||||
#runtime: SourceMapCodecRuntime | undefined;
|
||||
#supervisor: ProviderProcessSupervisor | undefined;
|
||||
#oversized = false;
|
||||
#processError: string | undefined;
|
||||
#outcome: CodecOutcome | undefined;
|
||||
#drainStop: CodecPhysicalObservation | undefined;
|
||||
readonly #onInterruption: () => void;
|
||||
constructor(
|
||||
readonly path: string,
|
||||
readonly dependencies: SourceMapDecoderDependencies,
|
||||
readonly deadline: ProviderStartupDeadline,
|
||||
) {}
|
||||
) {
|
||||
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();
|
||||
@@ -175,16 +214,33 @@ class OwnedCodec implements SourceMapCleanupOwner {
|
||||
(() => cleanupOwnedProcessGroup(spawned.ownership)),
|
||||
},
|
||||
{
|
||||
maxDiagnosticBytes: WEB_SOURCE_MAP_LIMITS.outputBytes + 1024 * 1024,
|
||||
onDiagnostic: (event) => {
|
||||
if (event.type === "error") this.#processError = event.message;
|
||||
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.totalBytes >
|
||||
(event.stream === "stdout"
|
||||
? WEB_SOURCE_MAP_LIMITS.outputBytes
|
||||
: 1024 * 1024)
|
||||
)
|
||||
this.#oversized = true;
|
||||
(event.truncated === true ||
|
||||
event.totalBytes >
|
||||
(event.stream === "stdout"
|
||||
? WEB_SOURCE_MAP_LIMITS.outputBytes
|
||||
: 1024 * 1024))
|
||||
) {
|
||||
this.#recordPhysicalFailure({ kind: "output-limit" });
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -194,61 +250,50 @@ class OwnedCodec implements SourceMapCleanupOwner {
|
||||
const supervisor = this.#supervisor;
|
||||
if (supervisor === undefined)
|
||||
throw new Error("Codec process was not acquired");
|
||||
while (!(await supervisor.waitForOutputClose(50))) this.check();
|
||||
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 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,
|
||||
},
|
||||
});
|
||||
throw this.#memoryLimitFailure(snapshot);
|
||||
return snapshot.stdout.text;
|
||||
}
|
||||
|
||||
check(): void {
|
||||
if (this.deadline.interruption === "cancelled")
|
||||
throw new AnalysisCancelledError(OPERATION);
|
||||
if (this.deadline.interruption === "timeout")
|
||||
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,
|
||||
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")
|
||||
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,
|
||||
WEB_SOURCE_MAP_LIMITS.decodeTimeoutMs,
|
||||
@@ -267,6 +312,144 @@ class OwnedCodec implements SourceMapCleanupOwner {
|
||||
});
|
||||
}
|
||||
|
||||
#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.`,
|
||||
{
|
||||
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),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async close(
|
||||
previousError: string | null,
|
||||
): Promise<ProviderCleanupError | undefined> {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { access } from "node:fs/promises";
|
||||
import { expect, it } from "vitest";
|
||||
import { SourceMapDecoder } from "./SourceMapDecoder.js";
|
||||
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
|
||||
import {
|
||||
SourceMapCleanupFailure,
|
||||
type SourceMapDecoderDependencies,
|
||||
} from "./SourceMapCommand.js";
|
||||
import { PrivateRuntimeRoot } from "../../process/PrivateRuntimeRoot.js";
|
||||
import { spawnOwnedProviderProcess } from "../../process/ProviderProcess.js";
|
||||
import {
|
||||
webSourceLocationArgs,
|
||||
webSourceLocationFixture,
|
||||
} from "../../../tests/fixtures/webSourceLocation.js";
|
||||
|
||||
const fixture = webSourceLocationFixture();
|
||||
const input = {
|
||||
text: fixture.sourceMap.text,
|
||||
url: fixture.sourceMap.url,
|
||||
path: fixture.sourceMap.file.path,
|
||||
position: webSourceLocationArgs.generated_position,
|
||||
};
|
||||
|
||||
it("preserves the exact caller reason before source-map admission", async () => {
|
||||
const reason = { caller: "pre-aborted source-map decode" };
|
||||
const controller = new AbortController();
|
||||
controller.abort(reason);
|
||||
const response = await new SourceMapDecoder().trace(input, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(response.ok).toBe(false);
|
||||
if (response.ok) throw new Error("expected cancellation");
|
||||
expect(response.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(response.error.cause).toBe(reason);
|
||||
});
|
||||
|
||||
it("retains failed cleanup ownership and retries it before another decode", async () => {
|
||||
const root = await PrivateRuntimeRoot.create({
|
||||
prefix: "rea-source-map-cleanup-retry-test-",
|
||||
});
|
||||
let cleanupAttempts = 0;
|
||||
const dependencies: SourceMapDecoderDependencies = {
|
||||
prepareOwnership: async () => {},
|
||||
createRuntime: async () => ({
|
||||
path: root.path,
|
||||
close: async () => {
|
||||
cleanupAttempts += 1;
|
||||
if (cleanupAttempts === 1)
|
||||
throw new Error("injected root cleanup failure");
|
||||
await root.close();
|
||||
},
|
||||
}),
|
||||
launcher: async (options) =>
|
||||
spawnOwnedProviderProcess({
|
||||
...options,
|
||||
arguments: [
|
||||
"-e",
|
||||
'process.stderr.write("physical exit 7"); process.exit(7)',
|
||||
],
|
||||
}),
|
||||
};
|
||||
const decoder = new SourceMapDecoder(dependencies);
|
||||
try {
|
||||
const failed = await decoder.trace(input);
|
||||
expect(failed.ok).toBe(false);
|
||||
if (failed.ok)
|
||||
throw new Error("expected physical failure with cleanup failure");
|
||||
expect(failed.error).toBeInstanceOf(SourceMapCleanupFailure);
|
||||
if (!(failed.error instanceof SourceMapCleanupFailure))
|
||||
throw new Error("expected source-map cleanup ownership");
|
||||
expect(failed.error.cleanupOwner).toBeDefined();
|
||||
expect(failed.error.diagnostics).toMatchObject({
|
||||
cleanup_failures: ["injected root cleanup failure"],
|
||||
});
|
||||
expect(failed.error.diagnostics).toMatchObject({
|
||||
previous_error: expect.stringContaining(
|
||||
"source-map-decoder adapter failed",
|
||||
),
|
||||
});
|
||||
await expect(access(root.path)).resolves.toBeUndefined();
|
||||
|
||||
const invalidInput = { ...input, text: null } as unknown as typeof input;
|
||||
const retried = await decoder.trace(invalidInput);
|
||||
expect(retried.ok).toBe(false);
|
||||
if (retried.ok)
|
||||
throw new Error("expected invalid input after cleanup retry");
|
||||
expect(retried.error._tag).toBe("AnalysisInputError");
|
||||
expect(cleanupAttempts).toBe(2);
|
||||
await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
} finally {
|
||||
await decoder.close();
|
||||
}
|
||||
});
|
||||
@@ -79,7 +79,9 @@ export class SourceMapDecoder implements WebSourceMapPort {
|
||||
const cleanupFailure = await this.#retryCleanup();
|
||||
if (cleanupFailure !== undefined) return err(cleanupFailure);
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
return err(
|
||||
new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }),
|
||||
);
|
||||
const request = sourceMapCodecInputSchema.safeParse({
|
||||
text: input.text,
|
||||
url: input.url,
|
||||
@@ -106,7 +108,9 @@ export class SourceMapDecoder implements WebSourceMapPort {
|
||||
encodedBytes += bytes;
|
||||
}
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
return err(
|
||||
new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }),
|
||||
);
|
||||
const serialized = encoded.join("");
|
||||
const response = await runSourceMapCommand(
|
||||
serialized,
|
||||
@@ -139,7 +143,9 @@ export class SourceMapDecoder implements WebSourceMapPort {
|
||||
),
|
||||
);
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
return err(
|
||||
new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }),
|
||||
);
|
||||
if (reply.data.state === "failure")
|
||||
return codecFailure(input.path, reply.data.reason, reply.data.message);
|
||||
const runtimeFailure = codecRuntimeFailure(reply.data.report.runtime);
|
||||
|
||||
Reference in new issue
Block a user