fix(source-maps): preserve decoder lifecycle facts (#10069)

* fix(source-maps): preserve decoder lifecycle facts

* fix(source-maps): preserve preparation failure on cancellation

* fix(source-maps): retain preparation and retry cleanup facts
This commit is contained in:
morluto authored and GitHub committed 2026-11-11 18:36:02 +08:00
1 parent d36ba58971
commit 8a2eb4ab61
4 files changed
+368 -32

No files matched your search

@@ -1,7 +1,9 @@
import { ProcessAdmissionError } from "../../process/ProcessAdmissionError.js";
import { access } from "node:fs/promises";
import { afterEach, expect, it, vi } from "vitest";
import {
AnalysisCancelledError,
AnalysisOutputError,
AnalysisResourceConstraintError,
AnalysisTimeoutError,
} from "../../domain/analysisErrorCore.js";
@@ -123,6 +125,67 @@ it("preserves Linux process inspection failure and recovery before launching the
});
});
it("retains a physical process inspection failure when the caller cancels concurrently", async () => {
const controller = new AbortController();
const reason = { caller: "cancelled during inspection" };
const physical = Object.assign(new Error("ps permission denied"), {
code: "EACCES",
});
const host = createSystemProcessOwnershipHost(
"linux",
{},
{
execFileOutput: async () => {
controller.abort(reason);
throw physical;
},
},
);
const result = await runSourceMapCommand(
"{}",
"/probe/source.map",
{ signal: controller.signal },
{
prepareOwnership: () => host.prepare!(),
launcher: async () => {
throw new Error("must not launch");
},
},
unexpectedCleanup,
);
if (result.ok) throw new Error("expected inspection failure");
expect(result.error).toBeInstanceOf(LinuxOwnedProcessPrerequisiteError);
expect(result.error.cause).toBe(physical);
expect(result.error.secondaryFailures).toHaveLength(1);
expect(result.error.secondaryFailures[0]).toMatchObject({
_tag: "AnalysisCancelledError",
cause: reason,
});
});
it("does not adopt an opaque typed cancellation reason as an ownership failure", async () => {
const controller = new AbortController();
const reason = new AnalysisOutputError("unrelated", "opaque caller reason");
const result = await runSourceMapCommand(
"{}",
"/probe/source.map",
{ signal: controller.signal },
{
prepareOwnership: async () => {
controller.abort(reason);
throw reason;
},
launcher: async () => {
throw new Error("must not launch");
},
},
unexpectedCleanup,
);
if (result.ok) throw new Error("expected cancellation");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
});
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)';
@@ -270,6 +333,11 @@ it("preserves an observed nonzero exit before caller cancellation during output
output_complete: false,
});
expect(result.error.capturedOutput?.truncated).toBe(true);
expect(
result.error.secondaryFailures.find(
(failure) => failure instanceof AnalysisCancelledError,
)?.cause,
).toBe(reason);
if (runtimePath === undefined) throw new Error("runtime was not acquired");
await expect(access(runtimePath)).rejects.toMatchObject({ code: "ENOENT" });
});
@@ -446,3 +514,28 @@ it("applies a non-default decode deadline and releases its runtime", async () =>
vi.useRealTimers();
}
});
it("retains a physical process admission error alongside cancellation", async () => {
const controller = new AbortController();
const reason = { caller: "during admission" };
const physical = new ProcessAdmissionError("reader_closed");
const result = await runSourceMapCommand(
"{}",
"/probe/source.map",
{ signal: controller.signal },
{
prepareOwnership: async () => {
controller.abort(reason);
throw physical;
},
},
unexpectedCleanup,
);
if (result.ok) throw new Error("expected admission failure");
expect(result.error._tag).toBe("ProviderAdapterError");
expect(result.error.cause).toBe(physical);
expect(result.error.secondaryFailures[0]).toBeInstanceOf(
AnalysisCancelledError,
);
expect(result.error.secondaryFailures[0]?.cause).toBe(reason);
});
+83 -19
View File
@@ -10,6 +10,7 @@ import { fileURLToPath } from "node:url";
import type { ExecutionOptions } from "../../application/AnalysisProvider.js";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
import { analysisErrorWithCancellation } from "../../domain/analysisErrorCancellation.js";
import {
AnalysisCancelledError,
AnalysisOutputError,
@@ -28,6 +29,8 @@ import {
} from "../../domain/webSourceLocation.js";
import { parseSourceMapReply } from "./SourceMapReply.js";
import { PrivateRuntimeRoot } from "../../process/PrivateRuntimeRoot.js";
import { DarwinProcessOwnershipInspectionError } from "../../process/DarwinProcessRunTokenReader.js";
import { ProcessAdmissionError } from "../../process/ProcessAdmissionError.js";
import {
RuntimeControlUnavailableError,
runtimeControlCapabilityError,
@@ -117,21 +120,46 @@ export const runSourceMapCommand = async (
options?.signal,
);
} catch (cause: unknown) {
const signal = options?.signal;
const preparation =
cause !== signal?.reason &&
cause instanceof DarwinProcessOwnershipInspectionError
? cause
: undefined;
const knownPhysical =
cause !== signal?.reason &&
(cause instanceof AnalysisError ||
cause instanceof ProcessAdmissionError ||
(preparation !== undefined && preparation.cancellation === undefined));
const physical =
cause instanceof AnalysisError
? cause
: new ProviderAdapterError(PROVIDER, OPERATION, {
cause,
...(preparation?.capturedOutput === undefined
? {}
: { capturedOutput: preparation.capturedOutput }),
diagnostics: {
map_path: path,
error_message: message(cause),
phase: "process_ownership_preparation",
},
});
const primary = signal?.aborted
? analysisErrorWithCancellation(
knownPhysical ? physical : undefined,
new AnalysisCancelledError(OPERATION, { cause: signal.reason }),
)
: physical;
if (preparation?.cleanupFailure === undefined) return err(primary);
const owner = new PreparationCleanup(preparation, path);
retainedCleanup.owner = owner;
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",
},
}),
analysisErrorWithCleanupFailure(
primary,
preparation.cleanupFailure,
OPERATION,
).bindCleanupDependencies([preparation.cleanupFailure], owner),
);
}
const deadline = new ProviderStartupDeadline(
@@ -162,15 +190,17 @@ export const runSourceMapCommand = async (
}
const cleanup = await command.close();
const observation = result.ok ? result.value : undefined;
const completed =
result.ok && options?.signal?.aborted
? err(
const completed = options?.signal?.aborted
? err(
analysisErrorWithCancellation(
result.ok ? undefined : result.error,
new AnalysisCancelledError(OPERATION, {
cause: options.signal.reason,
partialObservation: observation,
}),
)
: result;
),
)
: result;
if (cleanup === undefined) return completed;
if (observation !== undefined) cleanup.retainPartialObservation(observation);
retainedCleanup.owner = command;
@@ -185,6 +215,40 @@ export const runSourceMapCommand = async (
);
};
/** Keep the exact Darwin helper owner available after failed preparation cleanup. */
class PreparationCleanup implements SourceMapCleanupOwner {
constructor(
readonly preparation: DarwinProcessOwnershipInspectionError,
readonly path: string,
) {}
get cleanupResources(): readonly string[] {
return this.preparation.cleanupFailure?.cleanupResources ?? [];
}
async close(): Promise<ProviderCleanupError | undefined> {
const cleanup = this.preparation.cleanupFailure;
if (cleanup === undefined || !cleanup.cleanupIncomplete) return undefined;
try {
if (this.preparation.cleanupOwner !== undefined)
await this.preparation.cleanupOwner.close();
} catch (cause: unknown) {
return new SourceMapCleanupFailure(cleanup.cleanupResources, {
map_path: this.path,
cleanup_failures: [message(cause)],
}).bindCleanupDependencies([cleanup], this);
}
return cleanup.cleanupIncomplete
? new SourceMapCleanupFailure(cleanup.cleanupResources, {
map_path: this.path,
cleanup_failures: [
"Ownership preparation cleanup remains incomplete",
],
}).bindCleanupDependencies([cleanup], this)
: undefined;
}
}
class OwnedCodec implements SourceMapCleanupOwner {
readonly #runId = `rea-source-map-${randomUUID()}`;
#runtime: SourceMapCodecRuntime | undefined;
@@ -1,5 +1,7 @@
import { access } from "node:fs/promises";
import { expect, it } from "vitest";
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
import { DarwinProcessOwnershipInspectionError } from "../../process/DarwinProcessRunTokenReader.js";
import { SourceMapDecoder } from "./SourceMapDecoder.js";
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
@@ -35,19 +37,23 @@ it("preserves the exact caller reason before source-map admission", async () =>
expect(response.error.cause).toBe(reason);
});
it("retains failed cleanup ownership and retries it before another decode", async () => {
it("retains physical failure and late cancellation through cleanup failure and retry", async () => {
const root = await PrivateRuntimeRoot.create({
prefix: "rea-source-map-cleanup-retry-test-",
});
let cleanupAttempts = 0;
const controller = new AbortController();
const reason = { caller: "after physical exit during final cleanup" };
const dependencies: SourceMapDecoderDependencies = {
prepareOwnership: async () => {},
createRuntime: async () => ({
path: root.path,
close: async () => {
cleanupAttempts += 1;
if (cleanupAttempts === 1)
if (cleanupAttempts === 1) {
controller.abort(reason);
throw new Error("injected root cleanup failure");
}
await root.close();
},
}),
@@ -62,7 +68,7 @@ it("retains failed cleanup ownership and retries it before another decode", asyn
};
const decoder = new SourceMapDecoder(dependencies);
try {
const failed = await decoder.trace(input);
const failed = await decoder.trace(input, { signal: controller.signal });
expect(failed.ok).toBe(false);
if (failed.ok)
throw new Error("expected physical failure with cleanup failure");
@@ -75,6 +81,11 @@ it("retains failed cleanup ownership and retries it before another decode", asyn
},
});
expect(primary?.capturedOutput?.stderr).toBe("physical exit 7");
expect(primary?.secondaryFailures).toHaveLength(1);
expect(primary?.secondaryFailures[0]).toBeInstanceOf(
AnalysisCancelledError,
);
expect(primary?.secondaryFailures[0]?.cause).toBe(reason);
expect(failed.error.capturedOutput).toEqual(primary?.capturedOutput);
expect(failed.error.cause).toBeInstanceOf(AggregateError);
expect((failed.error.cause as AggregateError).errors[0]).toBe(primary);
@@ -104,6 +115,8 @@ it("retains failed cleanup ownership and retries it before another decode", asyn
expect(failed.error.cleanupResources).toEqual([]);
expect(projectAnalysisError(failed.error).code).toBe("execution_failure");
expect(failed.error.cleanupPrimary).toBe(primary);
expect(primary?.secondaryFailures).toHaveLength(1);
expect(primary?.secondaryFailures[0]?.cause).toBe(reason);
expect(failed.error.diagnostics).toMatchObject({
cleanup_error: {
details: {
@@ -241,3 +254,160 @@ it("attributes cleanup resources to the remaining process and directory owners",
await decoder.close();
}
});
it.each(["already closed", "queued at close"] as const)(
"reports source-map readiness without inventing cleanup ownership when %s",
async (phase) => {
const decoder = new SourceMapDecoder();
const response =
phase === "queued at close" ? decoder.trace(input) : undefined;
await decoder.close();
const result = await (response ?? decoder.trace(input));
if (result.ok) throw new Error("Expected closed decoder refusal");
expect(result.error._tag).toBe("AnalysisCapabilityUnavailableError");
expect(result.error.cleanupIncomplete).toBe(false);
expect(result.error.cleanupResources).toEqual([]);
await decoder.close();
},
);
it("retains cancellation while retrying an earlier failed cleanup", async () => {
const root = await PrivateRuntimeRoot.create({
prefix: "rea-source-map-retry-abort-",
});
const controller = new AbortController();
const reason = { caller: "during retry" };
let attempts = 0;
let release = false;
const decoder = new SourceMapDecoder({
prepareOwnership: async () => {},
createRuntime: async () => ({
path: root.path,
close: async () => {
attempts++;
if (!release) {
if (attempts === 2) controller.abort(reason);
throw new Error("root close failed");
}
await root.close();
},
}),
launcher: async (options) =>
spawnOwnedProviderProcess({
...options,
arguments: [
"-e",
'process.stderr.write("physical exit 7"); process.exit(7)',
],
}),
});
try {
const first = await decoder.trace(input);
if (first.ok) throw new Error("expected retained cleanup");
const retry = await decoder.trace(input, { signal: controller.signal });
if (retry.ok) throw new Error("expected retained cleanup on retry");
expect(controller.signal.aborted).toBe(true);
const cancellation = retry.error.secondaryFailures.find(
(failure) => failure instanceof AnalysisCancelledError,
);
expect(cancellation?.cause).toBe(reason);
expect(retry.error.cleanupIncomplete).toBe(true);
expect(retry.error.cleanupResources).toContain(root.path);
await expect(access(root.path)).resolves.toBeUndefined();
release = true;
await decoder.close();
await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" });
expect(first.error.cleanupIncomplete).toBe(false);
} finally {
release = true;
await decoder.close();
}
});
it("retains Darwin preparation cleanup ownership and cancellation through retry", async () => {
const root = await PrivateRuntimeRoot.create({
prefix: "rea-source-map-prep-owner-",
});
const controller = new AbortController();
const reason = { caller: "after preparation failure" };
const cleanup = new ProviderCleanupError("darwin-helper", [root.path], {
reason: "held",
});
let release = false;
const physical = new DarwinProcessOwnershipInspectionError(
"compiler failure",
{
cleanupFailure: cleanup,
cleanupOwner: {
close: async () => {
if (!release) throw new Error("still held");
await root.close();
cleanup.resolveCleanup();
},
},
},
);
const decoder = new SourceMapDecoder({
prepareOwnership: async () => {
controller.abort(reason);
throw physical;
},
});
try {
const failed = await decoder.trace(input, { signal: controller.signal });
if (failed.ok) throw new Error("expected failed preparation");
expect(failed.error.cleanupIncomplete).toBe(true);
expect(failed.error.cleanupResources).toContain(root.path);
expect(failed.error.cleanupPrimary?.cause).toBe(physical);
expect(
failed.error.cleanupPrimary?.secondaryFailures.find(
(entry) => entry instanceof AnalysisCancelledError,
)?.cause,
).toBe(reason);
await expect(decoder.close()).rejects.toMatchObject({
cleanupIncomplete: true,
});
expect(failed.error.cleanupResources).toContain(root.path);
await expect(access(root.path)).resolves.toBeUndefined();
release = true;
await decoder.close();
await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" });
expect(failed.error.cleanupIncomplete).toBe(false);
} finally {
release = true;
await decoder.close();
await root.close();
}
});
it("does not claim an opaque caller reason's Darwin cleanup owner", async () => {
const controller = new AbortController();
const cleanup = new ProviderCleanupError("foreign", ["opaque-resource"], {
reason: "opaque",
});
let ownerClosed = false;
const reason = new DarwinProcessOwnershipInspectionError(
"caller supplied reason",
{
cleanupFailure: cleanup,
cleanupOwner: {
close: async () => {
ownerClosed = true;
},
},
},
);
const decoder = new SourceMapDecoder({
prepareOwnership: async () => {
controller.abort(reason);
throw reason;
},
});
const result = await decoder.trace(input, { signal: controller.signal });
if (result.ok) throw new Error("expected cancellation");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
expect(result.error.cleanupIncomplete).toBe(false);
await decoder.close();
expect(ownerClosed).toBe(false);
});
+19 -10
View File
@@ -6,8 +6,10 @@ import {
import type { ExecutionOptions } from "../../application/AnalysisProvider.js";
import type { WebSourceMapPort } from "../../application/WebSourceLocationPorts.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import { analysisErrorWithCancellation } from "../../domain/analysisErrorCancellation.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisInputError,
} from "../../domain/analysisErrorCore.js";
import { ArtifactOperationError } from "../../domain/artifactOperationError.js";
@@ -23,7 +25,6 @@ import {
type SourceMapCleanupRetention,
type SourceMapDecoderDependencies,
} from "./SourceMapCommand.js";
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
import { jsonParts } from "../../domain/jsonSerialization.js";
const OPERATION = "trace_web_source_location";
@@ -43,22 +44,20 @@ export class SourceMapDecoder implements WebSourceMapPort {
if (this.#closing)
return Promise.resolve(
err(
new ProviderCleanupError(
new AnalysisCapabilityUnavailableError(
"source-map-decoder",
[],
{ reason: "Provider is closing" },
{ operation: OPERATION },
OPERATION,
"Provider is closing",
),
),
);
const operation = this.#tail.then(() =>
this.#closing
? err(
new ProviderCleanupError(
new AnalysisCapabilityUnavailableError(
"source-map-decoder",
[],
{ reason: "Provider is closing" },
{ operation: OPERATION },
OPERATION,
"Provider is closing",
),
)
: this.#trace(input, options),
@@ -75,7 +74,17 @@ export class SourceMapDecoder implements WebSourceMapPort {
options?: ExecutionOptions,
): Promise<Result<WebSourceMapReport, AnalysisError>> {
const cleanupFailure = await this.#retryCleanup();
if (cleanupFailure !== undefined) return err(cleanupFailure);
if (cleanupFailure !== undefined)
return err(
options?.signal?.aborted
? analysisErrorWithCancellation(
cleanupFailure,
new AnalysisCancelledError(OPERATION, {
cause: options.signal.reason,
}),
)
: cleanupFailure,
);
if (options?.signal?.aborted)
return err(
new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }),