diff --git a/src/pcap/PcapProvider.ts b/src/pcap/PcapProvider.ts index 92da142f85..0779b47770 100644 --- a/src/pcap/PcapProvider.ts +++ b/src/pcap/PcapProvider.ts @@ -50,6 +50,8 @@ import { createPcapEvidence } from "../domain/pcap/pcapEvidence.js"; import { preparePcapNativeBound } from "./PcapNativeBound.js"; import { PcapSnapshot } from "./PcapSnapshot.js"; import { readStableArtifact } from "../artifacts/readStableArtifact.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { artifactFileCancellationCause } from "../artifacts/ArtifactFileOperation.js"; import { ArtifactAnalysisResources } from "../process/ArtifactAnalysisResources.js"; import { readLinuxResourceLimits } from "../process/LinuxResourceLimits.js"; import { realpath } from "node:fs/promises"; @@ -133,6 +135,7 @@ const pcapCommandCleanupError = ( export interface PcapProviderOptions { readonly environment?: Readonly>; readonly launcher?: PcapCommandLauncher; + readonly artifactReader?: typeof readStableArtifact; } interface RetainedPcapCommand { @@ -153,16 +156,7 @@ interface RetainedSnapshotFailure { /** Inspect saved captures through bounded, owned tool processes and one stable input copy. */ export class PcapProvider { - readonly #resources = new ArtifactAnalysisResources( - "pcap", - "inspect_pcap_capture", - readStableArtifact, - readLinuxResourceLimits, - { - message: "A packet summary is already active; retry after it completes.", - details: { max_concurrent_inspections: 1 }, - }, - ); + readonly #resources: ArtifactAnalysisResources; private readonly environment: Readonly>; private readonly launcher: PcapCommandLauncher | undefined; private readonly wiresharkIdentities = new Map< @@ -182,6 +176,17 @@ export class PcapProvider { constructor(options: PcapProviderOptions = {}) { this.environment = options.environment ?? process.env; this.launcher = options.launcher; + this.#resources = new ArtifactAnalysisResources( + "pcap", + "inspect_pcap_capture", + options.artifactReader ?? readStableArtifact, + readLinuxResourceLimits, + { + message: + "A packet summary is already active; retry after it completes.", + details: { max_concurrent_inspections: 1 }, + }, + ); } private async run( @@ -664,17 +669,39 @@ export class PcapProvider { ); } } catch (cause) { - result = err( - cause instanceof AnalysisError - ? cause - : cause instanceof PcapConfigurationFailure - ? pcapToolUnavailable(request.operation, cause.message, { - cause, + const classifyArtifactFailure = (failure: unknown): AnalysisError => + failure instanceof AnalysisError + ? failure + : failure instanceof ArtifactReaderFailure && + failure.reason === "cancelled" + ? new AnalysisCancelledError(request.operation, { + cause: artifactFileCancellationCause(failure), + ...(failure.partialObservation === undefined + ? {} + : { partialObservation: failure.partialObservation }), }) : new AnalysisProtocolError( - `Packet-capture analysis failed: ${cause instanceof Error ? cause.message : String(cause)}`, - { cause }, - ), + `Packet-capture analysis failed: ${failure instanceof Error ? failure.message : String(failure)}`, + { cause: failure }, + ); + result = err( + cause instanceof ArtifactReaderFailure + ? this.#resources.cleanupFailure( + cause, + classifyArtifactFailure, + undefined, + request.operation, + ) + : cause instanceof AnalysisError + ? cause + : cause instanceof PcapConfigurationFailure + ? pcapToolUnavailable(request.operation, cause.message, { + cause, + }) + : new AnalysisProtocolError( + `Packet-capture analysis failed: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ), ); } if (signal?.aborted && result.ok) diff --git a/tests/boundary/pcap/pcapProviderCancellation.test.ts b/tests/boundary/pcap/pcapProviderCancellation.test.ts index 95b3312204..66a854c3a3 100644 --- a/tests/boundary/pcap/pcapProviderCancellation.test.ts +++ b/tests/boundary/pcap/pcapProviderCancellation.test.ts @@ -3,6 +3,8 @@ import { dirname, join } from "node:path"; import { expect, onTestFinished, test, vi } from "vitest"; import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js"; +import { ArtifactReaderFailure } from "../../../src/artifacts/ArtifactReader.js"; +import { readStableArtifact } from "../../../src/artifacts/readStableArtifact.js"; import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; import { resolvedAnalysisError } from "../../../src/domain/analysisErrorBase.js"; import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js"; @@ -62,6 +64,7 @@ const fixture = async ( reason: unknown, failedCleanupAttempts: number, activeVersion = false, + artifactReader: typeof readStableArtifact = readStableArtifact, ) => { const root = await createTestTempDirectory("rea-pcap-provider-cancel-"); const tools = await writeTools(root, mode, activeVersion); @@ -100,6 +103,7 @@ const fixture = async ( REA_CAPINFOS_COMMAND: tools.capinfos, }, launcher, + artifactReader, }); onTestFinished(async () => provider.close()); return { provider, root, capture, cleanupAttempts: () => cleanupAttempts }; @@ -123,6 +127,31 @@ it("availability preserves the producer's exact cancellation reason", async () = expect(failure).toMatchObject({ cause: reason }); }); +it("executable identity hashing preserves artifact-read cancellation", async () => { + const controller = new AbortController(); + const reason = { caller: "cancel executable identity read" }; + const { provider } = await fixture( + "active", + controller, + reason, + 0, + false, + async () => { + throw new ArtifactReaderFailure("cancelled", "identity read cancelled", { + cancellation: { reason }, + }); + }, + ); + const result = await provider.execute( + { operation: "inspect_pcap_client", input: {} }, + { signal: controller.signal }, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cause).toBe(reason); +}); + it("active cancellation stays primary through retained command and snapshot cleanup retry", async () => { const controller = new AbortController(); const reason = "caller cancellation token";