From 34239d445b56ed32366dc587529de2d1a3f1ff83 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Sat, 17 Oct 2026 04:27:55 -0700 Subject: [PATCH] fix(flutter): keep completed inspection facts when cancelled during snapshot cleanup (#4766) FlutterBuildProvider.execute checks the caller signal after its awaited snapshot-root removal. When the signal was aborted during a successful first removal, that check replaced the completed AnalysisExecution with a bare AnalysisCancelledError, so identify-flutter-build and inspect-dart-aot reported a typed cancellation without details.partial_observation and the shared CLI/MCP Evidence assembly never saw the facts. The post-cleanup check now keeps the caller's abort reason and retains a completed result as the cancellation's partial observation. The Evidence composition from executeProviderOperation moves into createAnalysisExecutionEvidence beside the provider contract, so the success path and the retained cancellation use one composer and carry identical Evidence. Failed results keep their existing classification. Fixes #4717 Co-authored-by: Claude Opus 5.5 --- src/application/AnalysisProvider.ts | 30 +++++++++++-- src/application/ProviderOperationExecution.ts | 28 +++++-------- src/flutter/FlutterBuildProvider.ts | 17 +++++++- .../filesystem/flutterBuildProvider.test.ts | 42 +++++++++++++++++++ 4 files changed, 95 insertions(+), 22 deletions(-) diff --git a/src/application/AnalysisProvider.ts b/src/application/AnalysisProvider.ts index 7d316cb795..bbcb8f768f 100644 --- a/src/application/AnalysisProvider.ts +++ b/src/application/AnalysisProvider.ts @@ -9,9 +9,13 @@ import type { AnalysisError } from "../domain/analysisErrorBase.js"; import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js"; import type { JsonValue } from "../domain/jsonValue.js"; import type { Result } from "../domain/result.js"; -import type { EvidenceLocation } from "../domain/evidence.js"; -import type { EvidenceSubjectTarget } from "../domain/evidence.js"; -import { jsonValueSchema } from "../domain/jsonValue.js"; +import { + createEvidence, + type Evidence, + type EvidenceLocation, + type EvidenceSubjectTarget, +} from "../domain/evidence.js"; +import { jsonObjectSchema, jsonValueSchema } from "../domain/jsonValue.js"; import type { ProgressReporter } from "./ProgressReporter.js"; import type { ProviderRejectionCode } from "../contracts/providerSelection.js"; import type { ProcessLineageObservation } from "../process/ProcessOwnership.js"; @@ -123,6 +127,26 @@ export function createAnalysisExecution( }; } +/** + * Compose one completed provider execution into its caller-visible Evidence, + * so success and a late cancellation that retains it carry identical facts. + */ +export const createAnalysisExecutionEvidence = ( + operation: string, + input: unknown, + execution: AnalysisExecution, + confidence: "observed" | "derived", +): Evidence => + createEvidence(execution.subject ?? undefined, execution.provider, { + operation, + parameters: jsonObjectSchema.parse(input), + result: execution.result, + rawResult: execution.rawResult, + limitations: execution.limitations, + locations: execution.locations, + confidence, + }); + /** Provider-neutral application capability for one analysis operation. */ export interface AnalysisOperationPort { execute( diff --git a/src/application/ProviderOperationExecution.ts b/src/application/ProviderOperationExecution.ts index b9d20f38ef..3cb46ee354 100644 --- a/src/application/ProviderOperationExecution.ts +++ b/src/application/ProviderOperationExecution.ts @@ -7,12 +7,12 @@ import { AnalysisCancelledError, AnalysisInputError, } from "../domain/analysisErrorCore.js"; -import { createEvidence, type Evidence } from "../domain/evidence.js"; -import { jsonObjectSchema } from "../domain/jsonValue.js"; +import type { Evidence } from "../domain/evidence.js"; import { err, ok, type Result } from "../domain/result.js"; -import type { - AnalysisExecution, - ExecutionOptions, +import { + createAnalysisExecutionEvidence, + type AnalysisExecution, + type ExecutionOptions, } from "./AnalysisProvider.js"; /** Validated caller-selected request carrying its operation input. */ @@ -55,19 +55,11 @@ export const executeProviderOperation = async < return err(new AnalysisInputError(operation, { cause: request.error })); const executed = await provider.execute(request.data, options); if (!executed.ok) return executed; - const execution = executed.value; - const evidence = createEvidence( - execution.subject ?? undefined, - execution.provider, - { - operation, - parameters: jsonObjectSchema.parse(request.data.input), - result: execution.result, - rawResult: execution.rawResult, - limitations: execution.limitations, - locations: execution.locations, - confidence, - }, + const evidence = createAnalysisExecutionEvidence( + operation, + request.data.input, + executed.value, + confidence, ); return completeProviderEvidence(evidence, options?.signal); }; diff --git a/src/flutter/FlutterBuildProvider.ts b/src/flutter/FlutterBuildProvider.ts index 11df7c0137..d50eaacf9b 100644 --- a/src/flutter/FlutterBuildProvider.ts +++ b/src/flutter/FlutterBuildProvider.ts @@ -12,6 +12,7 @@ import type { } from "../application/AnalysisProvider.js"; import { createAnalysisExecution, + createAnalysisExecutionEvidence, type AnalysisExecution, } from "../application/AnalysisProvider.js"; import type { FlutterRequest } from "../domain/flutter/flutterBuildAnalysis.js"; @@ -619,9 +620,23 @@ export class FlutterBuildProvider { } finally { if (snapshot !== undefined) await this.discardRoot(snapshot.root); } + // Cancellation observed after the awaited snapshot removal still keeps + // the completed facts, composed exactly as the uncancelled Evidence. if (signal?.aborted === true) return err( - new AnalysisCancelledError(request.operation, { cause: signal.reason }), + new AnalysisCancelledError(request.operation, { + cause: signal.reason, + ...(result.ok + ? { + partialObservation: createAnalysisExecutionEvidence( + request.operation, + request.input, + result.value, + "observed", + ), + } + : {}), + }), ); return result; } diff --git a/tests/boundary/filesystem/flutterBuildProvider.test.ts b/tests/boundary/filesystem/flutterBuildProvider.test.ts index a4f4deeac0..e9b61cac47 100644 --- a/tests/boundary/filesystem/flutterBuildProvider.test.ts +++ b/tests/boundary/filesystem/flutterBuildProvider.test.ts @@ -371,6 +371,48 @@ it.each(["close", "elf", "pool", "cleanup"] as const)( }, ); +it.each(["identify_flutter_build", "inspect_dart_aot"] as const)( + "%s keeps completed Evidence when cancelled during successful snapshot removal", + async (operation) => { + const path = await buildApk("removal-cancellation.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, + { path: "lib/arm64-v8a/libflutter.so", bytes: libflutter() }, + ]); + const controlProvider = new FlutterBuildProvider(); + onTestFinished(() => controlProvider.close()); + const control = await new FlutterBuildAnalysisService( + controlProvider, + ).execute(operation, { path }); + if (!control.ok) throw control.error; + + const controller = new AbortController(); + const reason = new Error("caller interrupted snapshot removal"); + const removedRoots: string[] = []; + const provider = new FlutterBuildProvider({ + removeRoot: async (root) => { + await rm(root, { recursive: true, force: true }); + removedRoots.push(root); + controller.abort(reason); + }, + }); + const result = await new FlutterBuildAnalysisService(provider).execute( + operation, + { path }, + { signal: controller.signal }, + ); + if (result.ok) throw new Error("cancellation must prevent success"); + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cause).toBe(reason); + expect(result.error.partialObservation).toEqual(control.value); + expect(removedRoots).toHaveLength(1); + await expect(stat(removedRoots[0]!)).rejects.toMatchObject({ + code: "ENOENT", + }); + await provider.close(); + expect(removedRoots).toHaveLength(1); + }, +); + it("projects each payload shape through the owned snapshot", async () => { const hash = REFERENCE_HASH; const agreeBoth = await buildApk("agree.apk", [