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();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user