fix(sourcemaps): bind cleanup status to remaining codec owners

This commit is contained in:
morluto
2026-10-24 03:01:32 +08:00
parent b5d83d2bff
commit d56a25e64f
3 changed files with 144 additions and 15 deletions
+6
View File
@@ -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
+23 -15
View File
@@ -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<ProviderCleanupError | undefined> {
@@ -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 =>
@@ -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();
}
});