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 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822
2026-10-17 04:27:55 -07:00
committed by GitHub
parent 83d2780008
commit 34239d445b
4 changed files with 95 additions and 22 deletions
+27 -3
View File
@@ -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(
+10 -18
View File
@@ -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);
};
+16 -1
View File
@@ -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;
}
@@ -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", [