diff --git a/docs/web-source-location.md b/docs/web-source-location.md index 743ad3a1f9..881a7056c7 100644 --- a/docs/web-source-location.md +++ b/docs/web-source-location.md @@ -95,6 +95,12 @@ heap limit is established. Cleanup may extend the deadline and an unconfirmed cleanup overrides success with `cleanup_incomplete` and the owning operation/resources. +The decoder releases its owned codec process before removing the private request +directory. A cleanup failure reports only the owners still pending: the process +run token while process release is unconfirmed, and the directory until removal +succeeds. A later successful retry settles every error bound to that command; +historical failure diagnostics remain available. + Complete evidence is bounded by a 4 MiB map, 32 MiB codec reply, 262144 decoded rows/segments and 64 nested section levels. Those structural budgets and the 192 MiB codec heap have no runtime override. Exceeding a budget returns an explicit diff --git a/src/javascript/sourceMaps/SourceMapCommand.ts b/src/javascript/sourceMaps/SourceMapCommand.ts index 476ac23158..3cf7485560 100644 --- a/src/javascript/sourceMaps/SourceMapCommand.ts +++ b/src/javascript/sourceMaps/SourceMapCommand.ts @@ -154,6 +154,7 @@ 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; @@ -462,6 +463,14 @@ class OwnedCodec implements SourceMapCleanupOwner { ); } + /** 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 { @@ -483,21 +492,20 @@ class OwnedCodec implements SourceMapCleanupOwner { 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, - }, - ); + 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 => diff --git a/src/javascript/sourceMaps/SourceMapDecoder.test.ts b/src/javascript/sourceMaps/SourceMapDecoder.test.ts index c350edf3dd..dc062ac01b 100644 --- a/src/javascript/sourceMaps/SourceMapDecoder.test.ts +++ b/src/javascript/sourceMaps/SourceMapDecoder.test.ts @@ -2,12 +2,14 @@ import { access } from "node:fs/promises"; import { expect, it } from "vitest"; import { SourceMapDecoder } from "./SourceMapDecoder.js"; import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; import { SourceMapCleanupFailure, type SourceMapDecoderDependencies, } from "./SourceMapCommand.js"; import { PrivateRuntimeRoot } from "../../process/PrivateRuntimeRoot.js"; import { spawnOwnedProviderProcess } from "../../process/ProviderProcess.js"; +import { cleanupOwnedProcessGroup } from "../../process/ProcessOwnership.js"; import { webSourceLocationArgs, webSourceLocationFixture, @@ -69,6 +71,7 @@ it("retains failed cleanup ownership and retries it before another decode", asyn if (!(failed.error instanceof SourceMapCleanupFailure)) throw new Error("expected source-map cleanup ownership"); expect(failed.error.cleanupOwner).toBeDefined(); + expect(failed.error.cleanupResources).toEqual([root.path]); expect(failed.error.diagnostics).toMatchObject({ cleanup_failures: ["injected root cleanup failure"], }); @@ -87,7 +90,119 @@ it("retains failed cleanup ownership and retries it before another decode", asyn expect(retried.error._tag).toBe("AnalysisInputError"); expect(cleanupAttempts).toBe(2); await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" }); + expect(failed.error.cleanupIncomplete).toBe(false); + expect(failed.error.cleanupResources).toEqual([]); + expect(projectAnalysisError(failed.error).code).not.toBe( + "cleanup_incomplete", + ); + expect(failed.error.diagnostics).toMatchObject({ + cleanup_failures: ["injected root cleanup failure"], + }); } finally { await decoder.close(); } }); + +it("settles every source-map cleanup failure when final close releases the exact runtime", async () => { + const root = await PrivateRuntimeRoot.create({ + prefix: "rea-source-map-final-cleanup-test-", + }); + let attempts = 0; + const decoder = new SourceMapDecoder({ + prepareOwnership: async () => {}, + createRuntime: async () => ({ + path: root.path, + close: async () => { + attempts += 1; + if (attempts <= 2) + throw new Error(`runtime cleanup attempt ${String(attempts)} failed`); + await root.close(); + }, + }), + launcher: async () => { + throw new Error("controlled launch failure"); + }, + }); + try { + const initial = await decoder.trace(input); + if (initial.ok) throw new Error("expected initial cleanup failure"); + const retry = await decoder.trace(input); + if (retry.ok) throw new Error("expected retry cleanup failure"); + for (const failure of [initial.error, retry.error]) { + expect(failure.cleanupIncomplete).toBe(true); + expect(failure.cleanupResources).toEqual([root.path]); + } + await expect(access(root.path)).resolves.toBeUndefined(); + await decoder.close(); + expect(attempts).toBe(3); + await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" }); + for (const failure of [initial.error, retry.error]) { + expect(failure.cleanupIncomplete).toBe(false); + expect(failure.cleanupResources).toEqual([]); + expect(projectAnalysisError(failure).code).not.toBe("cleanup_incomplete"); + expect(failure.diagnostics?.cleanup_failures).toEqual([ + expect.stringContaining("runtime cleanup attempt"), + ]); + } + } finally { + await decoder.close(); + } +}); + +it("attributes cleanup resources to the remaining process and directory owners", async () => { + const root = await PrivateRuntimeRoot.create({ + prefix: "rea-source-map-mixed-cleanup-test-", + }); + let denyProcessRelease = true; + let rootAttempts = 0; + let runId: string | undefined; + const decoder = new SourceMapDecoder({ + prepareOwnership: async () => {}, + createRuntime: async () => ({ + path: root.path, + close: async () => { + rootAttempts += 1; + if (rootAttempts === 1) + throw new Error("controlled directory cleanup failure"); + await root.close(); + }, + }), + launcher: async (options) => { + runId = options.runId; + const spawned = await spawnOwnedProviderProcess({ + ...options, + arguments: ["-e", "process.exit(0)"], + }); + return { + ...spawned, + cleanup: async () => + denyProcessRelease + ? { cleaned: false, reason: "controlled process cleanup failure" } + : spawned.cleanup === undefined + ? cleanupOwnedProcessGroup(spawned.ownership) + : spawned.cleanup(), + }; + }, + }); + try { + const initial = await decoder.trace(input); + if (initial.ok) throw new Error("expected process cleanup failure"); + expect(initial.error.cleanupResources).toEqual([runId, root.path]); + expect(rootAttempts).toBe(0); + denyProcessRelease = false; + await expect(decoder.close()).rejects.toMatchObject({ + cleanupIncomplete: true, + }); + expect(rootAttempts).toBe(1); + expect(initial.error.cleanupIncomplete).toBe(true); + expect(initial.error.cleanupResources).toEqual([root.path]); + await expect(access(root.path)).resolves.toBeUndefined(); + await decoder.close(); + expect(initial.error.cleanupIncomplete).toBe(false); + expect(initial.error.cleanupResources).toEqual([]); + await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + denyProcessRelease = false; + await decoder.close(); + } +});