diff --git a/src/application/python/PythonPycService.test.ts b/src/application/python/PythonPycService.test.ts index fb426512e7..e8bba03e2e 100644 --- a/src/application/python/PythonPycService.test.ts +++ b/src/application/python/PythonPycService.test.ts @@ -2,6 +2,7 @@ import { expect, it } from "vitest"; import { AnalysisInputError, AnalysisCancelledError, + AnalysisOutputError, } from "../../domain/analysisErrorCore.js"; import type { PythonPyc } from "../../domain/python/pythonPyc.js"; import { ok } from "../../domain/result.js"; @@ -105,12 +106,13 @@ it("records the selected artifact and the header plus marshal ranges", async () ]); }); -it("discards an observation if the caller cancels while the provider completes", async () => { +it("retains completed pyc Evidence and the opaque caller reason on late cancellation", async () => { const controller = new AbortController(); + const reason = new AnalysisInputError("opaque caller metadata"); const service = new PythonPycService({ identity, inspect: () => { - controller.abort(); + controller.abort(reason); return Promise.resolve(ok(report)); }, }); @@ -119,5 +121,54 @@ it("discards an observation if the caller cancels while the provider completes", { signal: controller.signal }, ); expect(result.ok).toBe(false); - if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisCancelledError); + if (result.ok) throw new Error("Expected cancellation"); + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cause).toBe(reason); + expect(result.error.partialObservation).toMatchObject({ + operation: "inspect_python_pyc", + normalized_result: report, + subject: { local_path: report.artifact.path }, + }); +}); + +it("retains a pre-admission cancellation reason without calling the pyc provider", async () => { + const reason = new Error("caller stopped before pyc admission"); + let calls = 0; + const service = new PythonPycService({ + identity, + inspect: async () => { + calls++; + return ok(report); + }, + }); + const result = await service.inspect( + { path: report.artifact.path }, + { signal: AbortSignal.abort(reason) }, + ); + if (result.ok) throw new Error("Expected cancellation"); + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cause).toBe(reason); + expect(result.error.partialObservation).toBeUndefined(); + expect(calls).toBe(0); +}); + +it("preserves malformed provider output instead of relabeling it as a late abort", async () => { + const controller = new AbortController(); + const service = new PythonPycService({ + identity, + inspect: async () => { + controller.abort(new Error("later caller stop")); + return ok({ + ...report, + artifact: { ...report.artifact, path: "/different.pyc" }, + }); + }, + }); + const result = await service.inspect( + { path: report.artifact.path }, + { signal: controller.signal }, + ); + if (result.ok) throw new Error("Expected output failure"); + expect(result.error).toBeInstanceOf(AnalysisOutputError); + expect(result.error.partialObservation).toBeUndefined(); }); diff --git a/src/application/python/PythonPycService.ts b/src/application/python/PythonPycService.ts index 464ce83d37..ef04a9de44 100644 --- a/src/application/python/PythonPycService.ts +++ b/src/application/python/PythonPycService.ts @@ -14,7 +14,8 @@ import { pythonPycSchema, } from "../../domain/python/pythonPyc.js"; import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; -import { err, ok, type Result } from "../../domain/result.js"; +import { err, type Result } from "../../domain/result.js"; +import { completeProviderEvidence } from "../ProviderOperationExecution.js"; const OPERATION = "inspect_python_pyc"; @@ -31,7 +32,9 @@ export class PythonPycService { options?: ExecutionOptions, ): Promise> { if (options?.signal?.aborted) - return err(new AnalysisCancelledError(OPERATION)); + return err( + new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }), + ); const input = inspectPythonPycInputSchema.safeParse(rawInput); if (!input.success) return err( @@ -51,8 +54,6 @@ export class PythonPycService { ); const result = await this.provider.inspect(input.data, options); if (!result.ok) return result; - if (options?.signal?.aborted) - return err(new AnalysisCancelledError(OPERATION)); const report = pythonPycSchema.safeParse(result.value); if ( !report.success || @@ -68,7 +69,7 @@ export class PythonPycService { ), ); const value = report.data; - return ok( + return completeProviderEvidence( createEvidence( { path: value.artifact.path, @@ -97,6 +98,7 @@ export class PythonPycService { ], }, ), + options?.signal, ); } } diff --git a/src/python/PythonPycProvider.ts b/src/python/PythonPycProvider.ts index 112087996c..c291e1b7f1 100644 --- a/src/python/PythonPycProvider.ts +++ b/src/python/PythonPycProvider.ts @@ -1,4 +1,8 @@ -import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js"; +import { + artifactFileCancellationCause, + artifactFilePrimaryFailure, + bindArtifactFileCleanup, +} from "../artifacts/ArtifactFileOperation.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { readStableArtifact } from "../artifacts/readStableArtifact.js"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; @@ -33,11 +37,7 @@ export const PYTHON_PYC_PROVIDER_IDENTITY = { const OPERATION = "inspect_python_pyc"; -const failureForPrimary = ( - cause: unknown, - input: InspectPythonPycInput, - signal: AbortSignal | undefined, -) => { +const failureForPrimary = (cause: unknown, input: InspectPythonPycInput) => { if (cause instanceof AnalysisError) return cause; if (cause instanceof ArtifactReaderFailure && cause.reason === "integrity") return new AnalysisArtifactChangedError( @@ -47,8 +47,9 @@ const failureForPrimary = ( { cause }, ); if (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled") - return new AnalysisCancelledError(OPERATION, { cause }); - if (signal?.aborted) return new AnalysisCancelledError(OPERATION, { cause }); + return new AnalysisCancelledError(OPERATION, { + cause: artifactFileCancellationCause(cause), + }); if (cause instanceof PythonPycResourceFailure) return new AnalysisResourceConstraintError( OPERATION, @@ -117,12 +118,8 @@ const failureForPrimary = ( }); }; -const failureFor = ( - cause: unknown, - input: InspectPythonPycInput, - signal: AbortSignal | undefined, -) => { - const primary = failureForPrimary(cause, input, signal); +const failureFor = (cause: unknown, input: InspectPythonPycInput) => { + const primary = failureForPrimary(artifactFilePrimaryFailure(cause), input); if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined) return primary; const cleanup = new ProviderCleanupError( @@ -168,7 +165,7 @@ export class PythonPycProvider implements PythonPycPort { ), ); } catch (cause) { - return err(failureFor(cause, input, options?.signal)); + return err(failureFor(cause, input)); } } } diff --git a/tests/process-global/stableArtifactConsumers.test.ts b/tests/process-global/stableArtifactConsumers.test.ts index 7e442f00df..6bd07285b9 100644 --- a/tests/process-global/stableArtifactConsumers.test.ts +++ b/tests/process-global/stableArtifactConsumers.test.ts @@ -702,6 +702,7 @@ const fileFailureProviders = [ ["PE certificates", () => new PeCertificatesProvider()], ["native exports", () => new NativeExportsProvider()], ["BEAM", () => new BeamProvider()], + ["Python pyc", () => new PythonPycProvider()], ] as const; const inspectFileFailure = ( @@ -710,6 +711,11 @@ const inspectFileFailure = ( signal?: AbortSignal, ) => { const options = signal === undefined ? undefined : { signal }; + if (provider instanceof PythonPycProvider) + return provider.inspect( + inspectPythonPycInputSchema.parse({ path }), + options, + ); if (provider instanceof ElfNotesProvider) return provider.inspect( inspectElfNotesInputSchema.parse({ path }), @@ -846,3 +852,39 @@ it.each( } }, ); + +it.each([false, true])( + "preserves a pyc permission failure through late cancellation (failed close: %s)", + async (failedClose) => { + const path = await selectedImage(); + const provider = new PythonPycProvider(); + const controller = new AbortController(); + const physical = Object.assign(new Error("pyc descriptor stat denied"), { + code: "EACCES", + }); + fault.blockNew = failedClose; + fault.readFailure = { cause: physical }; + fault.onClose = () => controller.abort(physical); + try { + const result = await provider.inspect( + inspectPythonPycInputSchema.parse({ path }), + { signal: controller.signal }, + ); + if (result.ok) throw new Error("Expected physical read failure"); + const primary = result.error.cleanupPrimary ?? result.error; + expect(primary).toBeInstanceOf(AnalysisAccessDeniedError); + expect(primary.cause).toBe(physical); + expect(result.error.cleanupIncomplete).toBe(failedClose); + expect(fault.handles).toHaveLength(1); + expect(fault.handles[0]!.fd >= 0).toBe(failedClose); + fault.blocked.clear(); + await provider.close(); + expect(result.error.cleanupIncomplete).toBe(false); + expect(fault.handles[0]!.fd).toBe(-1); + } finally { + fault.onClose = undefined; + fault.blocked.clear(); + await provider.close(); + } + }, +);