fix(cancellation): preserve caller abort reason in cancellation errors (#6144)

* fix(cancellation): preserve caller abort reason in cancellation errors

Several application, provider, and MCP boundaries constructed
AnalysisCancelledError, ArtifactReaderFailure, or
DetachedPgpSignatureError for an already-observed or newly-observed abort
without passing the caller's signal.reason as the error cause. The typed
result then reported cancellation with an undefined reason even though the
exact object supplied to AbortController.abort(reason) was still available.

Pass signal.reason as the cause at each producing cancellation site:
JavaScript artifact reconstruction and file abort checks, derived MCP
operation checkpoints, detached PGP verification, ASAR listing admission,
Dart AOT inspection and its provider guard, evidence-file analysis views,
binary layout and recorded crash admission, WASM inspection admission,
and the Kuna shared assembly-index wait.

Cancellation classification and cleanup behavior are unchanged.

Fixes morluto/rea#5935
Fixes morluto/rea#5944
Fixes morluto/rea#6100
Fixes morluto/rea#6101
Fixes morluto/rea#6110
Fixes morluto/rea#6113
Fixes morluto/rea#6121
Fixes morluto/rea#6131
Fixes morluto/rea#6132

* test(cancellation): assert the exact caller reason identity

Copied reasons satisfied toMatchObject, so the tests would pass even if the
caller's abort reason were replaced by a structurally equal copy. Capture the
rejection and assert the cause is the same object.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 35b247e9-c793-4b8f-979a-807c27112cf5

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 35b247e9-c793-4b8f-979a-807c27112cf5
This commit is contained in:
Alex Gorevski
2026-10-21 13:11:15 -07:00
committed by GitHub
parent 7ffae5a791
commit 4e28166626
22 changed files with 253 additions and 52 deletions
@@ -1023,27 +1023,32 @@ describe("inspectAnalysisViewWithFiles", () => {
it("returns typed cancellation for a cancelled file request", async () => {
const controller = new AbortController();
controller.abort();
const reason = Object.freeze({ requested: "view-stop" });
controller.abort(reason);
const parent = analysisViewCaptureEvidence(
environmentManifestCaptureFixture(),
);
expect(
await inspectAnalysisViewWithFiles(
{
source: {
kind: "evidence-file",
path: await writeEvidenceFile(parent),
},
view,
const result = await inspectAnalysisViewWithFiles(
{
source: {
kind: "evidence-file",
path: await writeEvidenceFile(parent),
},
undefined,
{ signal: controller.signal },
),
).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } });
view,
},
undefined,
{ signal: controller.signal },
);
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
expect(result.ok ? undefined : result.error.cause).toBe(reason);
});
it("returns typed cancellation when a running file request is aborted", async () => {
const controller = new AbortController();
const reason = Object.freeze({ requested: "running-view-stop" });
const parent = analysisViewCaptureEvidence(
environmentManifestCaptureFixture(),
);
@@ -1054,11 +1059,13 @@ describe("inspectAnalysisViewWithFiles", () => {
{ signal: controller.signal },
);
// Queued before the request yields after its synchronous work, so it is seen even if the read already finished.
setImmediate(() => controller.abort());
expect(await running).toMatchObject({
setImmediate(() => controller.abort(reason));
const result = await running;
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
expect(result.ok ? undefined : result.error.cause).toBe(reason);
});
it("leaves file sources to the file-aware entry point", () => {
@@ -97,7 +97,11 @@ export const inspectAnalysisViewValidatedWithFiles = async (
};
// A request cancelled during authentication reports cancellation, even when authentication failed.
if (await cancelled())
return err(new AnalysisCancelledError(INSPECT_ANALYSIS_VIEW_OPERATION));
return err(
new AnalysisCancelledError(INSPECT_ANALYSIS_VIEW_OPERATION, {
cause: options.signal?.reason,
}),
);
if (!loaded.ok) return loaded;
const id = loaded.value.evidence_id;
const projected = inspectAnalysisViewValidated(
@@ -105,9 +109,13 @@ export const inspectAnalysisViewValidatedWithFiles = async (
(evidenceId) => (evidenceId === id ? loaded.value : lookup?.(evidenceId)),
);
// A request cancelled during projection delivers and records nothing.
return (await cancelled())
? err(new AnalysisCancelledError(INSPECT_ANALYSIS_VIEW_OPERATION))
: projected;
if (await cancelled())
return err(
new AnalysisCancelledError(INSPECT_ANALYSIS_VIEW_OPERATION, {
cause: options.signal?.reason,
}),
);
return projected;
};
/** Authenticate borrowed inline Evidence after adapter envelope validation. */
@@ -64,6 +64,33 @@ it("preserves a provider's actionable unsupported reason", async () => {
expect(await service.inspect({ path: "/source.elf" })).toEqual(err(failure));
});
it("preserves the caller's exact abort reason before invocation", async () => {
const controller = new AbortController();
const reason = Object.freeze({ requested: "layout-stop" });
controller.abort(reason);
let calls = 0;
const service = new BinaryLayoutService({
inspectAndClose(input, options) {
return this.inspect(input, options);
},
identity: BINARY_LAYOUT_TEST_PROVIDER,
inspect: () => {
calls++;
return Promise.resolve(ok(binaryLayoutFixture()));
},
});
const result = await service.inspect(
{ path: "/artifacts/source-owned.elf" },
{ signal: controller.signal },
);
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
expect(result.ok ? undefined : result.error.cause).toBe(reason);
expect(calls).toBe(0);
});
it("cancels before invocation and after an acquired observation", async () => {
const controller = new AbortController();
const service = new BinaryLayoutService({
@@ -54,7 +54,11 @@ export class BinaryLayoutService {
) => ReturnType<BinaryLayoutPort["inspect"]>,
): Promise<Result<Evidence, AnalysisError>> {
if (options?.signal?.aborted)
return err(new AnalysisCancelledError(OPERATION));
return err(
new AnalysisCancelledError(OPERATION, {
cause: options.signal.reason,
}),
);
const input = inspectBinaryLayoutInputSchema.safeParse(rawInput);
if (!input.success)
return err(
@@ -93,6 +93,33 @@ it("preserves a provider's actionable unsupported reason", async () => {
expect(await service.inspect({ path: "/source.elf" })).toEqual(err(failure));
});
it("preserves the caller's exact abort reason before invocation", async () => {
const controller = new AbortController();
const reason = Object.freeze({ requested: "crash-stop" });
controller.abort(reason);
let calls = 0;
const service = new RecordedCrashService({
inspectAndClose(input, options) {
return this.inspect(input, options);
},
identity: RECORDED_CRASH_TEST_PROVIDER,
inspect: () => {
calls++;
return Promise.resolve(ok(recordedCrashFixture()));
},
});
const result = await service.inspect(
{ path: "/artifacts/source-owned.core" },
{ signal: controller.signal },
);
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
expect(result.ok ? undefined : result.error.cause).toBe(reason);
expect(calls).toBe(0);
});
it("cancels before invocation and after an acquired observation", async () => {
const controller = new AbortController();
const service = new RecordedCrashService({
@@ -55,7 +55,11 @@ export class RecordedCrashService {
) => ReturnType<RecordedCrashPort["inspect"]>,
): Promise<Result<Evidence, AnalysisError>> {
if (options?.signal?.aborted)
return err(new AnalysisCancelledError(OPERATION));
return err(
new AnalysisCancelledError(OPERATION, {
cause: options.signal.reason,
}),
);
const input = inspectRecordedCrashInputSchema.safeParse(rawInput);
if (!input.success)
return err(
+5 -1
View File
@@ -21,7 +21,11 @@ export const inspectDartAot = async (
): Promise<Result<Evidence, AnalysisError>> => {
const operation = "inspect_dart_aot";
if (options?.signal?.aborted === true)
return err(new AnalysisCancelledError(operation));
return err(
new AnalysisCancelledError(operation, {
cause: options.signal.reason,
}),
);
const parsed = dartAotInspectionInputSchema.safeParse(rawInput);
if (!parsed.success)
return err(
@@ -426,5 +426,6 @@ const abortIfNeeded = (signal?: AbortSignal): void => {
throw new ArtifactReaderFailure(
"cancelled",
"JavaScript artifact reconstruction cancelled",
{ cause: signal.reason },
);
};
@@ -101,13 +101,17 @@ it("cancels before provider work and delivers observed Evidence on success", asy
},
});
const controller = new AbortController();
controller.abort();
expect(
await service.inspect(
{ path: value.artifact.path },
{ signal: controller.signal },
),
).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } });
const reason = Object.freeze({ requested: "wasm-stop" });
controller.abort(reason);
const cancelled = await service.inspect(
{ path: value.artifact.path },
{ signal: controller.signal },
);
expect(cancelled).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
expect(cancelled.ok ? undefined : cancelled.error.cause).toBe(reason);
expect(calls).toBe(0);
expect(await service.inspect({ path: value.artifact.path })).toMatchObject({
ok: true,
+5 -1
View File
@@ -36,7 +36,11 @@ export class WasmArtifactService {
options?: ExecutionOptions,
): Promise<Result<Evidence, AnalysisError>> {
if (options?.signal?.aborted)
return err(new AnalysisCancelledError(OPERATION));
return err(
new AnalysisCancelledError(OPERATION, {
cause: options.signal.reason,
}),
);
const input = inspectWasmArtifactInputSchema.safeParse(rawInput);
if (!input.success)
return err(
+1
View File
@@ -52,6 +52,7 @@ export const readAsarListing = async (
throw new ArtifactReaderFailure(
"cancelled",
"ASAR operation cancelled",
{ cause: signal.reason },
);
}
const frame = pending.at(-1);
@@ -628,5 +628,6 @@ const abortIfNeeded = (signal?: AbortSignal): void => {
throw new ArtifactReaderFailure(
"cancelled",
"JavaScript artifact reconstruction cancelled",
{ cause: signal.reason },
);
};
+5 -1
View File
@@ -594,7 +594,11 @@ export class FlutterBuildProvider {
options?: ExecutionOptions,
): Promise<Result<AnalysisExecution, AnalysisError>> {
if (options?.signal?.aborted === true)
return err(new AnalysisCancelledError(request.operation));
return err(
new AnalysisCancelledError(request.operation, {
cause: options.signal.reason,
}),
);
const { path } = request.input;
const signal = options?.signal;
let snapshot: OwnedSnapshot | undefined;
+4 -1
View File
@@ -182,7 +182,10 @@ export class KunaProjectReader {
),
);
const index = await waitForAbortable(this.#assembly, signal);
if (index === ABORTED) return err(new AnalysisCancelledError(operation));
if (index === ABORTED)
return err(
new AnalysisCancelledError(operation, { cause: signal?.reason }),
);
return index.ok
? index
: err(
+20
View File
@@ -110,6 +110,26 @@ describe("detached binary PGP publisher verification", () => {
).toMatchObject({ ok: false, error: { reason: "cancelled" } });
});
it.each([null, "caller stop", { phase: "publisher-key" }])(
"preserves the caller's exact abort reason %j as the cancellation cause",
async (reason) => {
const controller = new AbortController();
controller.abort(reason);
const result = await verifyDetachedPgpSignature(
bytes,
signatures[0]!,
publicKey,
fingerprint,
controller.signal,
);
expect(result).toMatchObject({
ok: false,
error: { reason: "cancelled" },
});
expect(result.ok ? undefined : result.error.cause).toBe(reason);
},
);
it("preserves cancellation while loading the publisher key, before classifying its identity", async () => {
const controller = new AbortController();
const verification = verifyDetachedPgpSignature(
+1
View File
@@ -33,6 +33,7 @@ export const verifyDetachedPgpSignature = async (
new DetachedPgpSignatureError(
"cancelled",
"Package verification was cancelled.",
{ cause: signal?.reason },
),
);
const isAborted = () => signal?.aborted === true;
+44 -1
View File
@@ -9,12 +9,16 @@ import { EvidenceIntegrityError } from "../domain/evidenceErrors.js";
const context = (
controller: AbortController,
notifications: unknown[] = [],
onMessage: (message: string) => void = () => undefined,
) => ({
mcpReq: {
signal: controller.signal,
_meta: { progressToken: "derived-test" },
notify: (notification: unknown) => {
notifications.push(notification);
const message = (notification as { params?: { message?: unknown } })
.params?.message;
if (typeof message === "string") onMessage(message);
return Promise.resolve();
},
},
@@ -23,7 +27,8 @@ const context = (
describe("derived MCP operation boundary", () => {
it("cancels before computation and never evaluates the operation", async () => {
const controller = new AbortController();
controller.abort();
const reason = Object.freeze({ requested: "pre-compute-stop" });
controller.abort(reason);
let computed = false;
const result = await runDerivedOperation(
context(controller),
@@ -38,9 +43,47 @@ describe("derived MCP operation boundary", () => {
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
expect(result.ok ? undefined : result.error.cause).toBe(reason);
expect(computed).toBe(false);
});
it("preserves the request's abort reason when cancellation wins after computation", async () => {
const controller = new AbortController();
const reason = Object.freeze({ requested: "post-compute-stop" });
const result = await runDerivedOperation(
context(controller),
"compare",
() => {
controller.abort(reason);
return "must-not-publish";
},
);
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
expect(result.ok ? undefined : result.error.cause).toBe(reason);
});
it("preserves the request's abort reason when cancellation wins at terminal delivery", async () => {
const controller = new AbortController();
const reason = Object.freeze({ requested: "terminal-stop" });
const result = await runDerivedOperation(
context(controller, [], (message) => {
if (message.startsWith("complete:")) controller.abort(reason);
}),
"compare",
() => "must-not-publish",
);
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
expect(result.ok ? undefined : result.error.cause).toBe(reason);
});
it("lets cancellation win immediately before result publication", async () => {
const controller = new AbortController();
const result = await runDerivedOperation(
+15 -3
View File
@@ -31,7 +31,11 @@ export const runDerivedOperation = async <Value>(
});
await setImmediate();
if (context.mcpReq.signal.aborted)
return err(new AnalysisCancelledError(operation));
return err(
new AnalysisCancelledError(operation, {
cause: context.mcpReq.signal.reason,
}),
);
let value: Value;
try {
value = compute();
@@ -62,7 +66,11 @@ export const runDerivedOperation = async <Value>(
});
await setImmediate();
if (context.mcpReq.signal.aborted)
return err(new AnalysisCancelledError(operation));
return err(
new AnalysisCancelledError(operation, {
cause: context.mcpReq.signal.reason,
}),
);
await progress.report({
phase: "complete",
completed: 2,
@@ -71,6 +79,10 @@ export const runDerivedOperation = async <Value>(
terminal: true,
});
if (context.mcpReq.signal.aborted)
return err(new AnalysisCancelledError(operation));
return err(
new AnalysisCancelledError(operation, {
cause: context.mcpReq.signal.reason,
}),
);
return ok(value);
};
@@ -22,6 +22,7 @@ const archiveWith = async (bytes: Buffer) => {
it("interrupts expanded listing admission between traversal batches", async () => {
const controller = new AbortController();
const reason = Object.freeze({ requested: "listing-stop" });
const header = {
files: Object.fromEntries(
Array.from({ length: 4000 }, (_, index) => [
@@ -32,8 +33,10 @@ it("interrupts expanded listing admission between traversal batches", async () =
};
const listing = readAsarListing(header, controller.signal);
await setImmediate();
controller.abort();
await expect(listing).rejects.toMatchObject({ reason: "cancelled" });
controller.abort(reason);
const error: unknown = await listing.catch((caught: unknown) => caught);
expect(error).toMatchObject({ reason: "cancelled" });
expect((error as { cause?: unknown }).cause).toBe(reason);
});
it("rejects path expansion before publishing any inventory, preserving the input", async () => {
@@ -404,10 +404,14 @@ it("rejects traversal from a production reader seam and malformed ASAR container
it("preserves cancellation and explicit format diagnostics", async () => {
const root = await fixtureDirectory();
const controller = new AbortController();
controller.abort();
await expect(
reconstructJavaScriptArtifact({ input_path: root }, controller.signal),
).rejects.toMatchObject({ reason: "cancelled" });
const reason = Object.freeze({ requested: "reconstruction-stop" });
controller.abort(reason);
const cancelled: unknown = await reconstructJavaScriptArtifact(
{ input_path: root },
controller.signal,
).catch((caught: unknown) => caught);
expect(cancelled).toMatchObject({ reason: "cancelled" });
expect((cancelled as { cause?: unknown }).cause).toBe(reason);
await expect(
reconstructJavaScriptArtifact({ input_path: root, format: "asar" }),
).rejects.toMatchObject({
+22 -8
View File
@@ -273,13 +273,27 @@ it("rejects unavailable retention, invalid delivery and cancellation before prod
retention,
),
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
expect(
await inspectDartAot(
service,
{ path: "/missing.apk", detail: "summary" },
retention,
{ signal: AbortSignal.abort() },
),
).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } });
const reason = Object.freeze({ requested: "dart-stop" });
const cancelled = await inspectDartAot(
service,
{ path: "/missing.apk", detail: "summary" },
retention,
{ signal: AbortSignal.abort(reason) },
);
expect(cancelled).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
expect(cancelled.ok ? undefined : cancelled.error.cause).toBe(reason);
const executed = await service.execute(
"inspect_dart_aot",
{ path: "/missing.apk", abi: "x86_64" },
{ signal: AbortSignal.abort(reason) },
);
expect(executed).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
expect(executed.ok ? undefined : executed.error.cause).toBe(reason);
expect(execute).not.toHaveBeenCalled();
});
@@ -84,12 +84,17 @@ it.each([
const other = run(client, "procedure_assembly", { procedure: "parse" });
try {
await expect.poll(() => held.streams.length).toBe(1);
controller.abort();
const reason = Object.freeze({ requested: "reader-stop", operation });
controller.abort(reason);
await expect.poll(() => finished, { timeout: 1000 }).toBe(true);
expect(await cancelled).toMatchObject({
const cancelledResult = await cancelled;
expect(cancelledResult).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
expect(cancelledResult.ok ? undefined : cancelledResult.error.cause).toBe(
reason,
);
expect(held.streams[0]?.destroyed).toBe(false);
expect((await run(client, "health")).result).toBeDefined();
held.streams[0]?.end(bytes);