diff --git a/src/browser/assets/WebArtifactReads.ts b/src/browser/assets/WebArtifactReads.ts index 2f682e8138..9457bba79c 100644 --- a/src/browser/assets/WebArtifactReads.ts +++ b/src/browser/assets/WebArtifactReads.ts @@ -8,6 +8,7 @@ import { } from "../../artifacts/ArtifactFileOperation.js"; import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { completedStableArtifactObservation } from "../../artifacts/StableArtifactObservation.js"; import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { AnalysisAccessDeniedError, @@ -76,6 +77,11 @@ export const webArtifactReadError = ( context, signal, ); + const observation = completedStableArtifactObservation( + cause, + context.targetPath, + ); + if (observation !== undefined) primary.retainPartialObservation(observation); if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined) { const cleanup = new ProviderCleanupError( "captured-web-artifacts", diff --git a/src/browser/history/CaptureFailures.ts b/src/browser/history/CaptureFailures.ts index 2418c4b1d6..da429164c4 100644 --- a/src/browser/history/CaptureFailures.ts +++ b/src/browser/history/CaptureFailures.ts @@ -5,6 +5,7 @@ import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorClean import { bindArtifactFileCleanup, artifactFileCancellationCause, + artifactFilePrimaryFacts, } from "../../artifacts/ArtifactFileOperation.js"; import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { @@ -44,12 +45,14 @@ export const CAPTURE_PROCESS_MEMORY_LIMIT_REASON = /** Preserve the origin and typed cause of a historical capture failure. */ export const historicalCaptureFailure = ( input: InspectWebNetworkCaptureInput, - cause: unknown, + rawCause: unknown, phase: "capture-read" | "descriptor-read" | "decoder", options?: ExecutionOptions, heap: CaptureDecoderHeap = DEFAULT_CAPTURE_DECODER_HEAP, ): AnalysisError => { - if (cause instanceof AnalysisError) return cause; + const { cause, origin } = artifactFilePrimaryFacts(rawCause); + if (origin === "owned-or-classified" && cause instanceof AnalysisError) + return cause; const descriptorRead = phase === "descriptor-read"; const sourcePath = descriptorRead ? (input.protobuf?.schema?.descriptor_set_path ?? input.capture_path) @@ -58,7 +61,10 @@ export const historicalCaptureFailure = ( ? ["protobuf", "schema", "descriptor_set_path"] : ["capture_path"]; const sourceLabel = descriptorRead ? "descriptor set" : "capture"; - if (cause instanceof OwnedCommandFailure) { + if ( + origin === "owned-or-classified" && + cause instanceof OwnedCommandFailure + ) { if (cause.cleanupFailure !== null) return new ProviderCleanupError( input.format, @@ -128,6 +134,7 @@ export const historicalCaptureFailure = ( } if ( phase !== "decoder" && + origin === "owned-or-classified" && cause instanceof ArtifactReaderFailure && cause.reason === "integrity" ) @@ -137,12 +144,17 @@ export const historicalCaptureFailure = ( cause.message, { cause }, ); - if (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled") + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "cancelled" + ) return new AnalysisCancelledError(OPERATION, { cause: artifactFileCancellationCause(cause), }); if ( phase !== "decoder" && + origin === "owned-or-classified" && cause instanceof ArtifactReaderFailure && (cause.reason === "limit" || cause.reason === "format" || @@ -191,6 +203,10 @@ export const historicalCaptureFailure = ( }, ]); if ( + // Stable source reads report owned cancellation with ArtifactReaderFailure. + // Bare syscall rejections remain physical even when they equal the abort reason. + phase === "decoder" && + origin === "owned-or-classified" && options?.signal?.aborted === true && (cause === options.signal.reason || (cause instanceof Error && @@ -210,10 +226,13 @@ export const historicalCaptureFailure = ( ? { source_path: sourcePath, input_path: inputPath } : {}), reason: cause instanceof Error ? cause.message : String(cause), - ...(cause instanceof ArtifactReaderFailure + ...(origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure ? { failure_kind: cause.reason } : {}), - ...(cause instanceof OwnedCommandFailure && cause.snapshot !== null + ...(origin === "owned-or-classified" && + cause instanceof OwnedCommandFailure && + cause.snapshot !== null ? { exit_code: cause.snapshot.exitCode ?? null, signal: cause.snapshot.signal ?? null, diff --git a/src/browser/history/HistoricalCaptureDecoder.ts b/src/browser/history/HistoricalCaptureDecoder.ts index f6fbb894c5..0b7b58b18b 100644 --- a/src/browser/history/HistoricalCaptureDecoder.ts +++ b/src/browser/history/HistoricalCaptureDecoder.ts @@ -24,6 +24,7 @@ import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { completedStableArtifactObservation } from "../../artifacts/StableArtifactObservation.js"; import type { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { ArtifactResourceScope, @@ -392,12 +393,18 @@ export class HistoricalCaptureDecoder { reply_finalization_error: projectAnalysisError(cancellation), }); } - result = err( - redactExplicitFailure( - withCaptureReaderCleanup(error, replyFinalization ?? cause), - input.sensitive_values, - ), + const failure = withCaptureReaderCleanup( + error, + replyFinalization ?? cause, ); + if (phase === "capture-read") { + const snapshot = completedStableArtifactObservation( + cause, + input.capture_path, + ); + if (snapshot !== undefined) failure.retainPartialObservation(snapshot); + } + result = err(redactExplicitFailure(failure, input.sensitive_values)); } const completedCapture = result.ok ? result.value : undefined; let cleanupFailure: diff --git a/src/browser/history/protobuf/ProtobufCapture.ts b/src/browser/history/protobuf/ProtobufCapture.ts index 877ebc8b81..c6f9bd5ff4 100644 --- a/src/browser/history/protobuf/ProtobufCapture.ts +++ b/src/browser/history/protobuf/ProtobufCapture.ts @@ -6,6 +6,7 @@ import { } from "../CaptureRedaction.js"; import { createHash } from "node:crypto"; import { readStableArtifact } from "../../../artifacts/readStableArtifact.js"; +import { completedStableArtifactObservation } from "../../../artifacts/StableArtifactObservation.js"; import { historicalCaptureFailure, withCaptureReaderCleanup, @@ -546,7 +547,7 @@ export const decodeCaptureProtobuf = async ( undefined, resources, ).catch((cause: unknown) => { - throw withCaptureReaderCleanup( + const failure = withCaptureReaderCleanup( historicalCaptureFailure( input, cause, @@ -555,6 +556,11 @@ export const decodeCaptureProtobuf = async ( ), cause, ); + const snapshot = completedStableArtifactObservation(cause, path); + // The caller retains the sanitized capture as its partial observation. + if (snapshot !== undefined) + failure.retainDiagnostics({ descriptor_set_snapshot: snapshot }); + throw failure; }); const { ProtobufSchema } = await import("./ProtobufSchema.js"); const admitted = await ProtobufSchema.read( diff --git a/src/process/ArtifactAnalysisResources.ts b/src/process/ArtifactAnalysisResources.ts index 49a4582d4d..290c2bdff5 100644 --- a/src/process/ArtifactAnalysisResources.ts +++ b/src/process/ArtifactAnalysisResources.ts @@ -9,6 +9,7 @@ import { import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { readStableArtifact } from "../artifacts/readStableArtifact.js"; +import { completedStableArtifactObservation } from "../artifacts/StableArtifactObservation.js"; import { AnalysisError, type AnalysisCapturedOutput, @@ -176,6 +177,9 @@ export class ArtifactAnalysisResources { ): AnalysisError { const { path, maximumBytes, allowanceName, observedOutput } = input; if (cause instanceof AnalysisError) return cause; + const observation = completedStableArtifactObservation(cause, path); + if (cause instanceof ArtifactReaderFailure && observation !== undefined) + cause.retainPartialObservation(observation); if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined) return this.cleanupFailure( cause, @@ -189,6 +193,9 @@ export class ArtifactAnalysisResources { ...(observedOutput === undefined ? {} : { capturedOutput: observedOutput }), + ...(cause.partialObservation === undefined + ? {} + : { partialObservation: cause.partialObservation }), }); const reason = cause instanceof Error ? cause.message : String(cause); if (cause instanceof ArtifactReaderFailure && cause.reason === "limit") @@ -467,6 +474,8 @@ export class ArtifactAnalysisResources { partialObservation: cause.partialObservation, }); } + if (cause.partialObservation !== undefined) + (primary ?? failure).retainPartialObservation(cause.partialObservation); return primary === undefined ? failure : analysisErrorWithCleanupFailure( diff --git a/src/rust/RustBinaryProvider.ts b/src/rust/RustBinaryProvider.ts index f67fdc4264..e32a80ee6d 100644 --- a/src/rust/RustBinaryProvider.ts +++ b/src/rust/RustBinaryProvider.ts @@ -1,5 +1,6 @@ import { isSelectedRegularFileError } from "../filesystem/RegularFile.js"; import { readStableArtifact } from "../artifacts/readStableArtifact.js"; +import { completedStableArtifactObservation } from "../artifacts/StableArtifactObservation.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -285,6 +286,8 @@ const failureForPrimary = ( const failureFor = (cause: unknown, path: string): AnalysisError => { const primary = failureForPrimary(artifactFilePrimaryFacts(cause), path); + const observation = completedStableArtifactObservation(cause, path); + if (observation !== undefined) primary.retainPartialObservation(observation); if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined) return primary; const cleanup = new ProviderCleanupError( diff --git a/tests/process-global/historicalCaptureSourceSnapshots.test.ts b/tests/process-global/historicalCaptureSourceSnapshots.test.ts new file mode 100644 index 0000000000..3010d6cc09 --- /dev/null +++ b/tests/process-global/historicalCaptureSourceSnapshots.test.ts @@ -0,0 +1,297 @@ +import type { FileHandle } from "node:fs/promises"; +import { writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { join } from "node:path"; +import { afterEach, expect, it, vi } from "vitest"; + +const fault = vi.hoisted(() => ({ + path: "", + mode: "close" as + | "close" + | "cancel" + | "early" + | "early-cancel-close" + | "early-abort-close", + blocked: true, + controller: undefined as AbortController | undefined, + reason: { caller: "selected source finalization" }, + physical: Object.assign(new Error("selected descriptor stat denied"), { + code: "EACCES", + }), + handles: [] as { handle: FileHandle; close: () => Promise }[], +})); + +vi.mock("node:fs/promises", async (importOriginal) => { + const filesystem = await importOriginal(); + return { + ...filesystem, + open: async (...args: Parameters) => { + const handle = await filesystem.open(...args); + if (String(args[0]) !== fault.path) return handle; + const close = handle.close.bind(handle); + fault.handles.push({ handle, close }); + if ( + fault.mode === "early" || + fault.mode === "early-cancel-close" || + fault.mode === "early-abort-close" + ) + handle.stat = async () => { + if (fault.mode === "early-cancel-close") + fault.controller?.abort(fault.reason); + if (fault.mode === "early-abort-close") + fault.controller?.abort(fault.physical); + throw fault.physical; + }; + handle.close = async () => { + if ( + fault.blocked && + (fault.mode === "close" || + fault.mode === "early-cancel-close" || + fault.mode === "early-abort-close") + ) + throw new Error("controlled selected descriptor close failure"); + await close(); + if (fault.mode === "cancel") fault.controller?.abort(fault.reason); + }; + return handle; + }, + }; +}); + +import { HistoricalCaptureDecoder } from "../../src/browser/history/HistoricalCaptureDecoder.js"; +import { HAR_CAPTURE_PROVIDER_IDENTITY } from "../../src/browser/history/CaptureRelease.js"; +import { decodeHarCapture } from "../../src/browser/history/HarCapture.js"; +import { inspectWebNetworkCaptureInputSchema } from "../../src/domain/webNetworkCapture.js"; +import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js"; +import { primaryAnalysisError } from "../../src/domain/analysisErrorBase.js"; +import { analysisErrorProjectionSchema } from "../../src/contracts/errorSchemas.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { historicalHar } from "../fixtures/historicalHar.js"; + +const decoders: HistoricalCaptureDecoder[] = []; +afterEach(async () => { + fault.blocked = false; + for (const decoder of decoders.splice(0)) await decoder.close(); + for (const { handle, close } of fault.handles.splice(0)) + if (handle.fd >= 0) await close(); + fault.path = ""; + fault.controller = undefined; +}); + +const selectedSource = async ( + source: "capture" | "descriptor", + mode: typeof fault.mode, + sensitive = false, +) => { + const root = await createTestTempDirectory("rea-capture-source-snapshot-"); + const capturePath = join(root, "selected.har"); + const descriptorPath = join(root, "private-descriptor.pb"); + const text = JSON.stringify(historicalHar()); + const descriptor = Buffer.from([10, 0]); + await writeFile(capturePath, text); + await writeFile(descriptorPath, descriptor); + const path = source === "capture" ? capturePath : descriptorPath; + const bytes = source === "capture" ? Buffer.from(text) : descriptor; + const sha256 = createHash("sha256").update(bytes).digest("hex"); + fault.path = path; + fault.mode = mode; + fault.physical = + mode === "early-abort-close" + ? Object.assign( + new Error("physical descriptor failure with abort shape"), + { + name: "AbortError", + code: "ABORT_ERR", + }, + ) + : Object.assign(new Error("selected descriptor stat denied"), { + code: "EACCES", + }); + fault.blocked = true; + fault.controller = new AbortController(); + const command = vi.fn(async (_request: string, runtimePath: string) => { + await writeFile( + join(runtimePath, "reply.json"), + JSON.stringify({ ok: true, value: decodeHarCapture(text, []) }), + ); + return { command: process.execPath, arguments: ["-e", "process.exit(0)"] }; + }); + const decoder = new HistoricalCaptureDecoder( + [{ format: "har", identity: HAR_CAPTURE_PROVIDER_IDENTITY, command }], + process.env, + ); + decoders.push(decoder); + const result = await decoder.inspect( + inspectWebNetworkCaptureInputSchema.parse({ + capture_path: capturePath, + format: "har", + ...(source === "descriptor" + ? { + record_ordinals: [0], + protobuf: { + schema: { + descriptor_set_path: descriptorPath, + message_type: "fixture.Message", + }, + }, + } + : {}), + ...(sensitive + ? { sensitive_values: ["private-descriptor", sha256] } + : {}), + }), + { signal: fault.controller.signal }, + ); + if (result.ok) + throw new Error("Expected selected source acquisition failure"); + return { + decoder, + command, + error: result.error, + path, + bytes, + sha256, + capturePath, + }; +}; + +it.each(["capture", "descriptor"] as const)( + "retains completed %s identity through real descriptor cleanup and retry", + async (source) => { + const { decoder, command, error, path, bytes, sha256, capturePath } = + await selectedSource(source, "close"); + const projected = analysisErrorProjectionSchema.parse( + projectAnalysisError(error), + ); + expect(projected.code).toBe("cleanup_incomplete"); + const snapshot = { + kind: "file-snapshot-hash", + path, + sha256, + bytes: bytes.length, + }; + if (source === "capture") { + expect(projected.details?.partial_observation).toEqual(snapshot); + expect(command).not.toHaveBeenCalled(); + } else { + expect(projected.details?.diagnostics).toMatchObject({ + descriptor_set_snapshot: snapshot, + }); + expect(projected.details?.partial_observation).toMatchObject({ + artifact: { path: capturePath }, + records: [{ ordinal: 0 }], + }); + } + expect(fault.handles).toHaveLength(1); + expect(fault.handles[0]!.handle.fd).toBeGreaterThanOrEqual(0); + fault.blocked = false; + await decoder.close(); + expect(error.cleanupIncomplete).toBe(false); + expect(fault.handles[0]!.handle.fd).toBe(-1); + expect(projectAnalysisError(error).code).not.toBe("cleanup_incomplete"); + }, +); + +it.each(["capture", "descriptor"] as const)( + "retains %s identity and the exact caller reason after confirmed close cancellation", + async (source) => { + const { error, path, sha256, bytes, capturePath } = await selectedSource( + source, + "cancel", + ); + expect(projectAnalysisError(error).code).toBe("cancelled"); + expect(primaryAnalysisError(error).cause).toBe(fault.reason); + const snapshot = { + kind: "file-snapshot-hash", + path, + sha256, + bytes: bytes.length, + }; + const projected = projectAnalysisError(error); + if (source === "capture") + expect(projected.details?.partial_observation).toEqual(snapshot); + else { + expect(projected.details?.diagnostics).toMatchObject({ + descriptor_set_snapshot: snapshot, + }); + expect(projected.details?.partial_observation).toMatchObject({ + artifact: { path: capturePath }, + }); + } + expect(fault.handles[0]!.handle.fd).toBe(-1); + }, +); + +it.each(["capture", "descriptor"] as const)( + "does not invent completed %s identity after an early physical failure", + async (source) => { + const { error } = await selectedSource(source, "early"); + const projected = projectAnalysisError(error); + expect(projected.code).toBe("access_denied"); + expect(primaryAnalysisError(error).cause).toBe(fault.physical); + expect(projected.details?.diagnostics ?? {}).not.toHaveProperty( + "descriptor_set_snapshot", + ); + if (source === "capture") + expect(projected.details?.partial_observation).toBeUndefined(); + else + expect(projected.details?.partial_observation).toHaveProperty("records"); + }, +); + +it.each(["capture", "descriptor"] as const)( + "keeps %s physical failure primary when cancellation and failed close follow", + async (source) => { + const { decoder, error } = await selectedSource( + source, + "early-cancel-close", + ); + expect(projectAnalysisError(error).code).toBe("cleanup_incomplete"); + expect(primaryAnalysisError(error).cause).toBe(fault.physical); + fault.blocked = false; + await decoder.close(); + expect(projectAnalysisError(error).code).toBe("access_denied"); + expect( + projectAnalysisError(error).details?.diagnostics ?? {}, + ).not.toHaveProperty("descriptor_set_snapshot"); + }, +); + +it("redacts explicitly sensitive descriptor path and hash diagnostics without displacing capture facts", async () => { + const { error, sha256, capturePath } = await selectedSource( + "descriptor", + "close", + true, + ); + const projected = projectAnalysisError(error); + expect(JSON.stringify(projected)).not.toContain("private-descriptor"); + expect(JSON.stringify(projected)).not.toContain(sha256); + expect(projected.details?.diagnostics).toHaveProperty( + "descriptor_set_snapshot", + ); + expect(projected.details?.partial_observation).toMatchObject({ + artifact: { path: capturePath }, + records: [{ ordinal: 0 }], + }); +}); + +it.each(["capture", "descriptor"] as const)( + "preserves a physical %s failure even when it is also the abort reason", + async (source) => { + const { decoder, error } = await selectedSource( + source, + "early-abort-close", + ); + expect(projectAnalysisError(error).code).toBe("cleanup_incomplete"); + expect(primaryAnalysisError(error).cause).toBe(fault.physical); + fault.blocked = false; + await decoder.close(); + expect(projectAnalysisError(error).code).toBe("execution_failure"); + expect(projectAnalysisError(error).details?.diagnostics).toMatchObject({ + reason: fault.physical.message, + }); + expect( + projectAnalysisError(error).details?.diagnostics ?? {}, + ).not.toHaveProperty("descriptor_set_snapshot"); + }, +); diff --git a/tests/process-global/stableArtifactConsumers.test.ts b/tests/process-global/stableArtifactConsumers.test.ts index e8694a0b3f..7fb1d93530 100644 --- a/tests/process-global/stableArtifactConsumers.test.ts +++ b/tests/process-global/stableArtifactConsumers.test.ts @@ -91,6 +91,14 @@ import { import { RegularFileOperationCancelled } from "../../src/filesystem/RegularFile.js"; import { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js"; import { readStableArtifact } from "../../src/artifacts/readStableArtifact.js"; +import { ArtifactAnalysisResources } from "../../src/process/ArtifactAnalysisResources.js"; +import { readLinuxResourceLimits } from "../../src/process/LinuxResourceLimits.js"; +import { LocalWebSourceLocationArtifacts } from "../../src/browser/assets/WebSourceLocationArtifacts.js"; +import { ok } from "../../src/domain/result.js"; +import { + webSourceLocationArgs, + webSourceLocationFixture, +} from "../fixtures/webSourceLocation.js"; import { ArtifactReaderFailure } from "../../src/artifacts/ArtifactReader.js"; import { GoBinaryProvider } from "../../src/go/GoBinaryProvider.js"; import { PythonPycProvider } from "../../src/python/PythonPycProvider.js"; @@ -140,6 +148,100 @@ const staticProviders = [ ["IL2CPP metadata", () => new Il2cppMetadataProvider()], ] as const; +it.each( + ["Rust service", "selected producer input", "source map"].flatMap( + (consumer) => + ["close failure", "close cancellation", "physical failure"].map( + (outcome) => ({ consumer, outcome }), + ), + ), +)( + "retains completed caller input for $consumer through $outcome", + async ({ consumer, outcome }) => { + const path = await selectedImage(); + const bytes = Buffer.from("selected completed input"); + await writeFile(path, bytes); + const controller = new AbortController(); + const reason = { source: "caller input finalization" }; + const physical = Object.assign(new Error("input metadata denied"), { + code: "EACCES", + }); + if (outcome !== "close failure") + fault.onClose = () => controller.abort(reason); + if (outcome === "physical failure") fault.readFailure = { cause: physical }; + const { sourceMap: _sourceMap, ...script } = webSourceLocationFixture(); + const rust = new RustBinaryService(new RustBinaryProvider()); + const web = new LocalWebSourceLocationArtifacts({ + load: () => Promise.resolve(ok(script)), + }); + const resources = new ArtifactAnalysisResources( + "selected-test", + "inspect_signal_recording", + readStableArtifact, + readLinuxResourceLimits, + { message: "busy", details: {} }, + ); + const close = () => + consumer === "Rust service" + ? rust.close() + : consumer === "source map" + ? web.close() + : resources.close(); + try { + const result = + consumer === "Rust service" + ? await rust.inspect({ path }, { signal: controller.signal }) + : consumer === "source map" + ? await web.load( + { + ...webSourceLocationArgs, + source_map: { ...webSourceLocationArgs.source_map, path }, + }, + { signal: controller.signal }, + ) + : await resources.run(async () => + ok( + await resources.readSelectedArtifact({ + path, + maximumBytes: 1024, + allowanceName: "fixture input", + observedOutput: undefined, + signal: controller.signal, + }), + ), + ); + if (result.ok) + throw new Error("Expected descriptor finalization failure"); + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.partialObservation).toEqual( + outcome === "physical failure" + ? undefined + : { + kind: "file-snapshot-hash", + path, + sha256: createHash("sha256").update(bytes).digest("hex"), + bytes: bytes.length, + }, + ); + if (outcome === "physical failure") + expect(result.error.cleanupPrimary?.cause).toBe(physical); + if (outcome === "close cancellation") + expect(result.error.cleanupPrimary?.cause).toBe(reason); + await expect(close()).rejects.toBeDefined(); + expect(result.error.cleanupIncomplete).toBe(true); + fault.blocked.clear(); + await close(); + expect(result.error.cleanupIncomplete).toBe(false); + expect(fault.handles).toHaveLength(1); + expect(fault.handles[0]?.fd).toBe(-1); + } finally { + fault.onClose = undefined; + fault.blocked.clear(); + await close(); + } + }, +); + const expectIl2cppSource = ( error: AnalysisError, path: string,