diff --git a/src/application/WebSourceLocationService.test.ts b/src/application/WebSourceLocationService.test.ts index 4a13482a51..fd68983f87 100644 --- a/src/application/WebSourceLocationService.test.ts +++ b/src/application/WebSourceLocationService.test.ts @@ -146,7 +146,8 @@ it.each([ ); it("honors cancellation both before acquisition and across decoder completion", async () => { const first = new AbortController(); - first.abort(); + const firstReason = { caller: "before source-map acquisition" }; + first.abort(firstReason); const response = await new WebSourceLocationService( { load: () => { @@ -156,15 +157,18 @@ it("honors cancellation both before acquisition and across decoder completion", decoder, ).trace(args, { signal: first.signal }); expect(response.ok).toBe(false); + if (!response.ok) expect(response.error.cause).toBe(firstReason); const controller = new AbortController(); + const lateReason = { caller: "after source-map provider completion" }; const late = await service(undefined, { trace: (input) => { - controller.abort(); + controller.abort(lateReason); return decoder.trace(input); }, }).trace(args, { signal: controller.signal }); if (late.ok) throw new Error("expected cancellation"); expect(late.error).toBeInstanceOf(AnalysisCancelledError); + expect(late.error.cause).toBe(lateReason); expect(parseEvidence(late.error.partialObservation)).toMatchObject({ operation: "trace_web_source_location", confidence: "derived", diff --git a/src/application/WebSourceLocationService.ts b/src/application/WebSourceLocationService.ts index 55731d5ac7..4dd71ab9ab 100644 --- a/src/application/WebSourceLocationService.ts +++ b/src/application/WebSourceLocationService.ts @@ -88,7 +88,9 @@ export class WebSourceLocationService { options?: ExecutionOptions, ): Promise> { if (options?.signal?.aborted) - return err(new AnalysisCancelledError(OPERATION)); + return err( + new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }), + ); const parsed = webSourceLocationInputSchema.safeParse(rawInput); if (!parsed.success) return err( @@ -114,7 +116,9 @@ export class WebSourceLocationService { if (!pairing.ok) return pairing; const generatedOffset = pairing.value; if (options?.signal?.aborted) - return err(new AnalysisCancelledError(OPERATION)); + return err( + new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }), + ); const traced = await this.decoder.trace( { text: map.text, diff --git a/src/javascript/sourceMaps/SourceMapCommand.test.ts b/src/javascript/sourceMaps/SourceMapCommand.test.ts new file mode 100644 index 0000000000..c0b40cdd30 --- /dev/null +++ b/src/javascript/sourceMaps/SourceMapCommand.test.ts @@ -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(); + } +}); diff --git a/src/javascript/sourceMaps/SourceMapCommand.ts b/src/javascript/sourceMaps/SourceMapCommand.ts index 9e88379527..4b24e8ed9a 100644 --- a/src/javascript/sourceMaps/SourceMapCommand.ts +++ b/src/javascript/sourceMaps/SourceMapCommand.ts @@ -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; } +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 { 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, + 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 { diff --git a/src/javascript/sourceMaps/SourceMapDecoder.test.ts b/src/javascript/sourceMaps/SourceMapDecoder.test.ts new file mode 100644 index 0000000000..c350edf3dd --- /dev/null +++ b/src/javascript/sourceMaps/SourceMapDecoder.test.ts @@ -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(); + } +}); diff --git a/src/javascript/sourceMaps/SourceMapDecoder.ts b/src/javascript/sourceMaps/SourceMapDecoder.ts index fc26cde14e..220e588679 100644 --- a/src/javascript/sourceMaps/SourceMapDecoder.ts +++ b/src/javascript/sourceMaps/SourceMapDecoder.ts @@ -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);