fix(evidence): share queued cancellation handling

This commit is contained in:
morluto committed 2026-10-27 12:06:15 +08:00
1 parent 0793b6c3ca
commit 3e8923c6eb
2 files changed
+39 -9

No files matched your search

@@ -11,7 +11,7 @@ import {
analysisViewJavaScriptEvidence,
analysisViewLayoutEvidence,
} from "../../../tests/fixtures/analysisView.js";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../../contracts/javascript/javascriptRuntimeReconciliationExample.js";
import {
@@ -41,6 +41,7 @@ import {
scenarioCaptureFixture,
} from "../../../tests/fixtures/browserScenarioCapture.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import * as evidenceFileSource from "./evidenceFileSource.js";
it.each(["inline", "retained-evidence", "invalid"] as const)(
"refuses an already cancelled %s view before source admission",
@@ -1222,6 +1223,37 @@ describe("inspectAnalysisView", () => {
});
});
it("prioritizes queued cancellation over a file authentication failure", async () => {
const controller = new AbortController();
const reason = Object.freeze({ requested: "cancel-after-authentication" });
// Model an event callback which queues the caller's cancellation notification
// as authentication finishes. The service must dispatch that notification
// before accepting the authentication failure.
const read = vi
.spyOn(evidenceFileSource, "readEvidenceFileSource")
.mockImplementationOnce(async () => {
setImmediate(() => setImmediate(() => controller.abort(reason)));
return err(new EvidenceIntegrityError("Invalid selected Evidence"));
});
try {
const result = await inspectAnalysisView(
{
source: { kind: "evidence-file", path: "/selected-evidence.json" },
view: manifestView,
},
undefined,
{ signal: controller.signal },
);
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
expect(result.ok ? undefined : result.error.cause).toBe(reason);
} finally {
read.mockRestore();
}
});
describe("inspectAnalysisView scenario parents", () => {
const view = manifestView;
@@ -1,6 +1,5 @@
import { parseAnalysisInput } from "../AnalysisInput.js";
import { isNativeObservationOperation } from "../../domain/native/nativeObservation.js";
import { setImmediate } from "node:timers/promises";
import { dispatchQueuedInterrupts } from "../../process/queuedInterrupts.js";
import { readImmutableEvidenceBundle } from "../EvidenceBundleFiles.js";
import { INSPECT_ANALYSIS_VIEW_OPERATION } from "../../domain/analysisView/analysisViewErrors.js";
@@ -96,13 +95,11 @@ const inspectFileView = async (
options,
input.source.evidence_id,
);
// Authentication and projection are synchronous; yield so a pending abort is dispatched before each check.
const cancelled = async (): Promise<boolean> => {
await setImmediate();
return options.signal?.aborted === true;
};
// Authentication and projection are synchronous; dispatch queued callbacks and
// the signal notifications they enqueue before accepting either result.
await dispatchQueuedInterrupts();
// A request cancelled during authentication reports cancellation, even when authentication failed.
if (await cancelled())
if (options.signal?.aborted)
return err(
new AnalysisCancelledError(INSPECT_ANALYSIS_VIEW_OPERATION, {
cause: options.signal?.reason,
@@ -114,8 +111,9 @@ const inspectFileView = async (
{ ...input, source: { kind: "retained-evidence", evidence_id: id } },
(evidenceId) => (evidenceId === id ? loaded.value : lookup?.(evidenceId)),
);
await dispatchQueuedInterrupts();
// A request cancelled during projection delivers and records nothing.
if (await cancelled())
if (options.signal?.aborted)
return err(
new AnalysisCancelledError(INSPECT_ANALYSIS_VIEW_OPERATION, {
cause: options.signal?.reason,