diff --git a/src/application/analysisView/AnalysisViewService.test.ts b/src/application/analysisView/AnalysisViewService.test.ts index 0ca35d271a..fc0519fd31 100644 --- a/src/application/analysisView/AnalysisViewService.test.ts +++ b/src/application/analysisView/AnalysisViewService.test.ts @@ -42,6 +42,10 @@ import { scenarioCaptureFixture, } from "../../../tests/fixtures/browserScenarioCapture.js"; import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; +import { OwnedFileHandle } from "../../filesystem/OwnedFileHandle.js"; +import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; +import * as queuedInterrupts from "../../process/queuedInterrupts.js"; +import { createEvidenceBundle } from "../../domain/evidenceBundle.js"; import * as evidenceFileSource from "./evidenceFileSource.js"; it("projects retained GPU source facts while delivering the caller's rebound subject", () => { @@ -1329,11 +1333,235 @@ it("prioritizes queued cancellation over a file authentication failure", async ( error: { _tag: "AnalysisCancelledError" }, }); expect(result.ok ? undefined : result.error.cause).toBe(reason); + if (result.ok) throw new Error("Expected cancellation"); + expect(result.error.secondaryFailures).toHaveLength(1); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "cancelled", + details: { + diagnostics: { source_error: { code: "evidence_integrity_mismatch" } }, + }, + }); } finally { read.mockRestore(); } }); +it("retries the owned descriptor when queued cancellation supersedes file cleanup failure", async () => { + const controller = new AbortController(); + const path = join( + await createTestTempDirectory("rea-view-cleanup-"), + "evidence.json", + ); + await writeFile(path, JSON.stringify(analysisViewCaptureEvidence())); + const resources = new ArtifactResourceScope(); + const originalClose = OwnedFileHandle.prototype.close; + const owners = new Set(); + let failClose = true; + const close = vi + .spyOn(OwnedFileHandle.prototype, "close") + .mockImplementation(async function (this: OwnedFileHandle) { + owners.add(this); + if (failClose) { + setImmediate(() => setImmediate(() => controller.abort("stop"))); + throw new Error("Fixture descriptor close unavailable"); + } + return originalClose.call(this); + }); + try { + const result = await inspectAnalysisView( + { source: { kind: "evidence-file", path }, view: manifestView }, + undefined, + { signal: controller.signal, resources }, + ); + if (result.ok) + throw new Error("Expected cancellation with pending cleanup"); + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.cleanupResources).toEqual([path]); + expect(result.error.cleanupPrimary?.secondaryFailures).toHaveLength(1); + expect(projectAnalysisError(result.error)).toMatchObject({ + details: { + diagnostics: { + primary_error: { + details: { + diagnostics: { source_error: { code: "cleanup_incomplete" } }, + }, + }, + }, + }, + }); + expect(resources.hasPending).toBe(true); + expect(owners.size).toBe(1); + expect([...owners][0]!.handle.fd).toBeGreaterThanOrEqual(0); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "cleanup_incomplete", + details: { diagnostics: { primary_error: { code: "cancelled" } } }, + }); + failClose = false; + await resources.retryCleanup(); + expect([...owners][0]!.handle.fd).toBe(-1); + expect(resources.hasPending).toBe(false); + expect(result.error.cleanupIncomplete).toBe(false); + expect(result.error.cleanupResources).toEqual([]); + } finally { + failClose = false; + await resources.close(); + close.mockRestore(); + } +}); + +it.each(["file", "bundle"] as const)( + "preserves the %s source authentication cancellation precedence", + async (kind) => { + const path = join( + await createTestTempDirectory("rea-view-invalid-"), + "invalid.json", + ); + await writeFile(path, "{}"); + const controller = new AbortController(); + const dispatch = queuedInterrupts.dispatchQueuedInterrupts; + const interrupt = vi + .spyOn(queuedInterrupts, "dispatchQueuedInterrupts") + .mockImplementation(async () => { + await dispatch(); + controller.abort("after-authentication"); + }); + try { + const result = await inspectAnalysisView( + { + source: + kind === "file" + ? { kind: "evidence-file", path } + : { + kind: "retained-evidence", + evidence_id: "ev_" + "a".repeat(64), + }, + view: manifestView, + }, + undefined, + { + signal: controller.signal, + ...(kind === "bundle" ? { evidenceBundlePath: path } : {}), + }, + ); + expect(result).toMatchObject({ + ok: false, + error: + kind === "file" + ? { _tag: "AnalysisCancelledError", cause: "after-authentication" } + : { _tag: "EvidenceIntegrityError" }, + }); + } finally { + interrupt.mockRestore(); + } + }, +); + +it.each(["file", "bundle"] as const)( + "preserves completed %s view Evidence when cancellation arrives after projection", + async (kind) => { + const parent = analysisViewCaptureEvidence(); + const path = join( + await createTestTempDirectory("rea-view-completion-"), + "source.json", + ); + await writeFile( + path, + JSON.stringify(kind === "file" ? parent : createEvidenceBundle([parent])), + ); + const controller = new AbortController(); + const dispatch = queuedInterrupts.dispatchQueuedInterrupts; + let stages = 0; + const interrupt = vi + .spyOn(queuedInterrupts, "dispatchQueuedInterrupts") + .mockImplementation(async () => { + await dispatch(); + stages += 1; + if (stages === 2) controller.abort("after-projection"); + }); + try { + const result = await inspectAnalysisView( + { + source: + kind === "file" + ? { kind: "evidence-file", path } + : { kind: "retained-evidence", evidence_id: parent.evidence_id }, + view: manifestView, + }, + undefined, + { + signal: controller.signal, + ...(kind === "bundle" ? { evidenceBundlePath: path } : {}), + }, + ); + if (result.ok) throw new Error("Expected cancellation after projection"); + expect(result.error).toMatchObject({ + _tag: "AnalysisCancelledError", + partialObservation: { + operation: "inspect_analysis_view", + evidence_links: [parent.evidence_id], + }, + }); + } finally { + interrupt.mockRestore(); + } + }, +); + +it.each(["file", "bundle"] as const)( + "preserves a produced %s projection failure when cancellation arrives", + async (kind) => { + const parent = createEvidence( + undefined, + { id: "fixture", name: "Fixture", version: "1" }, + { + operation: "inspect_recorded_crash", + parameters: {}, + result: { path: "/artifacts/crash.core" }, + }, + ); + const path = join( + await createTestTempDirectory("rea-view-failure-"), + "source.json", + ); + await writeFile( + path, + JSON.stringify(kind === "file" ? parent : createEvidenceBundle([parent])), + ); + const controller = new AbortController(); + const dispatch = queuedInterrupts.dispatchQueuedInterrupts; + let stages = 0; + const interrupt = vi + .spyOn(queuedInterrupts, "dispatchQueuedInterrupts") + .mockImplementation(async () => { + await dispatch(); + if (++stages === 2) controller.abort("after-failure"); + }); + try { + const result = await inspectAnalysisView( + { + source: + kind === "file" + ? { kind: "evidence-file", path } + : { kind: "retained-evidence", evidence_id: parent.evidence_id }, + view: { kind: "summary" }, + }, + undefined, + { + signal: controller.signal, + ...(kind === "bundle" ? { evidenceBundlePath: path } : {}), + }, + ); + expect(controller.signal.aborted).toBe(true); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisUnsupportedTargetError" }, + }); + } finally { + interrupt.mockRestore(); + } + }, +); + describe("inspectAnalysisView scenario parents", () => { const view = manifestView; diff --git a/src/application/analysisView/AnalysisViewService.ts b/src/application/analysisView/AnalysisViewService.ts index ed7f74c9a6..676fff685e 100644 --- a/src/application/analysisView/AnalysisViewService.ts +++ b/src/application/analysisView/AnalysisViewService.ts @@ -1,3 +1,5 @@ +import { projectAnalysisErrorWithoutCapturedOutput } from "../../domain/analysisErrorProjection.js"; +import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; import { parseAnalysisInput } from "../AnalysisInput.js"; import { isNativeObservationOperation } from "../../domain/native/nativeObservation.js"; import { dispatchQueuedInterrupts } from "../../process/queuedInterrupts.js"; @@ -99,27 +101,16 @@ const inspectFileView = async ( // the signal notifications they enqueue before accepting either result. await dispatchQueuedInterrupts(); // A request cancelled during authentication reports cancellation, even when authentication failed. - if (options.signal?.aborted) - return err( - new AnalysisCancelledError(INSPECT_ANALYSIS_VIEW_OPERATION, { - cause: options.signal?.reason, - }), - ); - if (!loaded.ok) return loaded; - const id = loaded.value.evidence_id; + const admitted = cancelledFileViewResult(loaded, options.signal); + if (!admitted.ok) return admitted; + const id = admitted.value.evidence_id; const projected = inspectAnalysisViewValidated( { ...input, source: { kind: "retained-evidence", evidence_id: id } }, - (evidenceId) => (evidenceId === id ? loaded.value : lookup?.(evidenceId)), + (evidenceId) => (evidenceId === id ? admitted.value : lookup?.(evidenceId)), ); await dispatchQueuedInterrupts(); - // A request cancelled during projection delivers and records nothing. - if (options.signal?.aborted) - return err( - new AnalysisCancelledError(INSPECT_ANALYSIS_VIEW_OPERATION, { - cause: options.signal?.reason, - }), - ); - return projected; + if (!projected.ok) return projected; + return cancelledFileViewResult(projected, options.signal, projected.value); }; /** Resolve the caller's exact retained parent from an authenticated portable bundle. */ @@ -147,26 +138,51 @@ const inspectBundleView = async ( ); // Authentication and projection are synchronous; dispatch queued interrupts before accepting either result. await dispatchQueuedInterrupts(); + // Preserve an already-produced bundle failure before considering a later abort. if (!loaded.ok) return loaded; - if (signal?.aborted) - return err( - new AnalysisCancelledError("inspect_analysis_view", { - cause: signal.reason, - }), - ); + const admitted = cancelledFileViewResult(loaded, signal); + if (!admitted.ok) return admitted; const projected = inspectAnalysisViewValidated( { source: input.source, view: input.view }, - (id) => loaded.value.records.find((record) => record.evidence_id === id), + (id) => admitted.value.records.find((record) => record.evidence_id === id), ); await dispatchQueuedInterrupts(); - if (projected.ok && signal?.aborted) - return err( - new AnalysisCancelledError("inspect_analysis_view", { - cause: signal.reason, - partialObservation: projected.value, - }), - ); - return projected; + if (!projected.ok) return projected; + return cancelledFileViewResult(projected, signal, projected.value); +}; + +/** Retain completed work and pending cleanup when this stage accepts cancellation. + * File-source admission explicitly gives queued cancellation precedence over + * authentication errors; callers preserve other already-produced failures. + */ +const cancelledFileViewResult = ( + result: Result, + signal?: AbortSignal, + completedEvidence?: Evidence, +): Result => { + if (!signal?.aborted) return result; + const cancellation = new AnalysisCancelledError( + INSPECT_ANALYSIS_VIEW_OPERATION, + { + cause: signal.reason, + ...(completedEvidence !== undefined + ? { partialObservation: completedEvidence } + : {}), + }, + ); + if (!result.ok) + cancellation.retainSecondaryFailure(result.error, { + source_error: projectAnalysisErrorWithoutCapturedOutput(result.error), + }); + return err( + !result.ok && result.error.cleanupIncomplete + ? analysisErrorWithCleanupFailure( + cancellation, + result.error, + INSPECT_ANALYSIS_VIEW_OPERATION, + ).bindCleanupDependencies([result.error]) + : cancellation, + ); }; /** Project a parsed request whose selected source is inline or retained. */ diff --git a/src/domain/analysisView/analysisView.test.ts b/src/domain/analysisView/analysisView.test.ts index 595fc9ff3f..d52c2433fc 100644 --- a/src/domain/analysisView/analysisView.test.ts +++ b/src/domain/analysisView/analysisView.test.ts @@ -625,6 +625,69 @@ it("projects layout summary, facet, item, and stable pages", () => { }); }); +it.each(["sections", "symbols"] as const)( + "selects %s by exact source bytes and preserves opaque names by index", + (collection) => { + const layout = analysisViewLayoutFixture(); + const rows = layout[collection]; + const original = rows[0]; + if (original === undefined) throw new Error("missing layout row"); + const opaque = { + ...original, + name: { + ...original.name, + display: "�name", + bytes_base64: Buffer.from([0xff, ...Buffer.from("name")]).toString( + "base64", + ), + }, + }; + const valid = { + ...rows[1], + name: { + ...original.name, + display: "provider-formatted-name", + bytes_base64: Buffer.from("�name").toString("base64"), + }, + }; + const parent = { + ...layoutParent(), + normalizedResult: { + ...layout, + [collection]: [opaque, valid], + }, + }; + const selected = projectAnalysisView(parent, { + kind: "item", + collection, + selector: { name: "�name" }, + }); + expect(selected).toMatchObject({ ok: true, value: { item: valid } }); + expect( + projectAnalysisView(parent, { + kind: "item", + collection, + selector: { name: "\ud800name" }, + }).ok, + ).toBe(false); + expect( + projectAnalysisView(parent, { + kind: "item", + collection, + selector: { name: "provider-formatted-name" }, + }).ok, + ).toBe(false); + expect( + projectAnalysisView(parent, { + kind: "item", + collection, + selector: { index: 0 }, + }), + ).toMatchObject({ ok: true, value: { item: opaque } }); + expect(parent.normalizedResult[collection]).toEqual([opaque, valid]); + }, +); + it("rejects ambiguous layout names with candidate indexes", () => { const parent = layoutParent(); const result = projectAnalysisView(parent, { diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index 5be679240c..c3858ae2d3 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -327,7 +327,13 @@ const facetViewSchema = z.strictObject({ const indexSelectorSchema = z.strictObject({ index: z.int().nonnegative(), }); -const nameSelectorSchema = z.strictObject({ name: z.string() }); +const nameSelectorSchema = z.strictObject({ + name: z + .string() + .describe( + "Exact UTF-8 section or symbol name, matched against retained source bytes. Use index for non-UTF-8 names or unresolved name bytes.", + ), +}); const pathSelectorSchema = z.strictObject({ path: z .string() diff --git a/src/domain/analysisView/binaryLayoutView.ts b/src/domain/analysisView/binaryLayoutView.ts index 0c4840ff82..b4b7865ff4 100644 --- a/src/domain/analysisView/binaryLayoutView.ts +++ b/src/domain/analysisView/binaryLayoutView.ts @@ -40,17 +40,20 @@ const layoutUnknowns = (layout: BinaryLayout): readonly string[] => { return unknowns; }; -const displayName = (name: LayoutName): string => name.display; - const namedMatches = ( rows: readonly Row[], name: string, -): readonly { readonly index: number; readonly row: Row }[] => - rows.flatMap((row, index) => - row.name.unknown_reason === null && displayName(row.name) === name +): readonly { readonly index: number; readonly row: Row }[] => { + const bytes = Buffer.from(name, "utf8"); + // Display text may replace malformed source bytes or use provider formatting. + if (bytes.toString("utf8") !== name) return []; + const encoded = bytes.toString("base64"); + return rows.flatMap((row, index) => + row.name.unknown_reason === null && row.name.bytes_base64 === encoded ? [{ index, row }] : [], ); +}; const selectNamed = ( collection: "sections" | "symbols",