From 0441f646ca922d41f303b52dbc9ee813d43da030 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 29 Oct 2026 07:47:18 +0000 Subject: [PATCH] fix(hopper): retain completed caller scans on failure Attach the native reply and collected forward facts when reconciliation fails or is cancelled, and check scan stop codes separately from their wording. Live observations keep REA evidence ids rather than provider supplied ones, and the real Hopper verifier uses advertised MCP schemas. --- docs/native-investigation.md | 2 +- scripts/verify-real-hopper-callers.mjs | 7 +- src/application/WorkflowEvidence.test.ts | 18 +- .../binary/BinarySessionLiveness.test.ts | 5 +- src/domain/analysisErrorBase.ts | 2 + .../native/hopperCallerReconciliation.ts | 103 +++++++ src/hopper/HopperCallerReconciliation.test.ts | 119 +++++++- src/hopper/HopperCallerReconciliation.ts | 256 ++++++++++++++---- src/hopper/HopperProvider.test.ts | 93 ++++++- .../composedWorkflowEvidenceLinks.test.ts | 22 +- .../analysis-sessions/binarySession.test.ts | 40 ++- 11 files changed, 575 insertions(+), 92 deletions(-) create mode 100644 src/domain/native/hopperCallerReconciliation.ts diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 1cf913a11f..fe576791cd 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -687,7 +687,7 @@ complete observation. summary keeps every decoded document and node/edge/coverage counts and retains the complete Evidence; page rows with `inspect_analysis_view` `view: {kind: "interface-builder", collection: "nodes", document: -"Contents/Resources/Base.lproj/MainMenu.nib", offset: 0, limit: 64}`. + "Contents/Resources/Base.lproj/MainMenu.nib", offset: 0, limit: 64}`. `document` selects rows whose evidence names that exact `documents[].relative_path` (nodes and edges only); `source_truncated` keeps graph incompleteness visible on every page. `verify:interface-builder` compiles diff --git a/scripts/verify-real-hopper-callers.mjs b/scripts/verify-real-hopper-callers.mjs index ca47ba5e78..7407114e6c 100644 --- a/scripts/verify-real-hopper-callers.mjs +++ b/scripts/verify-real-hopper-callers.mjs @@ -9,6 +9,7 @@ import { createRequire } from "node:module"; import { join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { promisify } from "node:util"; +import { artifactMcpCall } from "./lib/artifact-e2e.mjs"; import { snapshotHopperRuntime } from "./lib/real-hopper-cleanup.mjs"; import { HOPPER_TARGET_LEASE_DIRECTORY } from "../dist/process/HopperLease.js"; @@ -71,7 +72,8 @@ transport.stderr?.on("data", (chunk) => { }); const call = async (name, args, options = {}) => { process.stderr.write(`calling ${name}\n`); - const result = await client.callTool( + const result = await artifactMcpCall( + client, { name, arguments: args }, { timeout: 120000, ...options }, ); @@ -248,7 +250,8 @@ try { let partial = false; let resolved = false; try { - await client.callTool( + await artifactMcpCall( + client, { name: "get_call_graph", arguments: query }, { timeout: 120000, diff --git a/src/application/WorkflowEvidence.test.ts b/src/application/WorkflowEvidence.test.ts index 8580da8b63..deed631640 100644 --- a/src/application/WorkflowEvidence.test.ts +++ b/src/application/WorkflowEvidence.test.ts @@ -662,12 +662,20 @@ it("clears workflow state when its callback throws", async () => { execute: async (_operation, _parameters, options) => { captured = options?.workScope; captured?.set(owner, "in flight"); - return ok(createAnalysisExecution(null, { id: "fixture", name: "Fixture", version: "1" })); + return ok( + createAnalysisExecution(null, { + id: "fixture", + name: "Fixture", + version: "1", + }), + ); }, }; - await expect(executeComposedWorkflow(analysis, async (observed) => { - await observed.execute("health", {}); - throw new Error("failed workflow"); - })).rejects.toThrow("failed workflow"); + await expect( + executeComposedWorkflow(analysis, async (observed) => { + await observed.execute("health", {}); + throw new Error("failed workflow"); + }), + ).rejects.toThrow("failed workflow"); expect(captured?.get(owner)).toBeUndefined(); }); diff --git a/src/application/binary/BinarySessionLiveness.test.ts b/src/application/binary/BinarySessionLiveness.test.ts index 91227b31e6..a39c4ebec1 100644 --- a/src/application/binary/BinarySessionLiveness.test.ts +++ b/src/application/binary/BinarySessionLiveness.test.ts @@ -636,7 +636,10 @@ const killableDeepProvider = (options: { }, kill: ( state: - "exited" | "unreachable" | "unknown" | "not_started" = "exited", + | "exited" + | "unreachable" + | "unknown" + | "not_started" = "exited", ) => { alive = false; deadState = state; diff --git a/src/domain/analysisErrorBase.ts b/src/domain/analysisErrorBase.ts index d758ee0739..71c59942bd 100644 --- a/src/domain/analysisErrorBase.ts +++ b/src/domain/analysisErrorBase.ts @@ -251,6 +251,7 @@ import type { } from "./process/processCapture.js"; import type { FileOffsetPartialObservation } from "./native/fileOffsetPartialObservation.js"; import type { NativeCallPartialObservation } from "./native/nativeCallPartialObservation.js"; +import type { HopperCallerReconciliationPartial } from "./native/hopperCallerReconciliation.js"; import type { ElectronActivePartialObservation } from "./javascript/electronActiveObservation.js"; import type { JavaScriptRuntimeObservation } from "./javascript/javascriptRuntimeObservation.js"; import type { @@ -314,6 +315,7 @@ export type AnalysisPartialObservation = | FileSnapshotPartialObservation | PartialProcessCaptureObservation | NativeCallPartialObservation + | HopperCallerReconciliationPartial | FileOffsetPartialObservation | ElectronActivePartialObservation | BrowserScenarioPartialObservation diff --git a/src/domain/native/hopperCallerReconciliation.ts b/src/domain/native/hopperCallerReconciliation.ts new file mode 100644 index 0000000000..4086337f05 --- /dev/null +++ b/src/domain/native/hopperCallerReconciliation.ts @@ -0,0 +1,103 @@ +import { z } from "zod"; + +import { jsonValueSchema } from "../jsonValue.js"; +import { unknownRelationshipCoverageSchema } from "../relationshipCoverage.js"; + +/** Hopper bridge page allowance for one procedure in a caller scan. */ +export const HOPPER_CALLER_PAGE_ALLOWANCE_BYTES = 512 * 1024; + +/** Encoded scan facts retained for one reconciliation. */ +export const HOPPER_CALLER_SCAN_RETENTION_BYTES = 4 * 1024 * 1024; + +const hopperCallerScanStopSchema = z.discriminatedUnion("code", [ + z.strictObject({ code: z.literal("complete") }), + z.strictObject({ + code: z.literal("work_budget"), + budget_ms: z.int().positive(), + }), + z.strictObject({ code: z.literal("inventory_changed") }), + z.strictObject({ + code: z.literal("page_allowance"), + allowance_bytes: z.literal(HOPPER_CALLER_PAGE_ALLOWANCE_BYTES), + }), + z.strictObject({ + code: z.literal("retention_budget"), + allowance_bytes: z.literal(HOPPER_CALLER_SCAN_RETENTION_BYTES), + }), + z.strictObject({ code: z.literal("cancelled") }), + z.strictObject({ code: z.literal("page_failure") }), + z.strictObject({ code: z.literal("invalid_page") }), + z.strictObject({ code: z.literal("invalid_progress") }), +]); + +/** Structured progress for one Hopper caller scan, separate from its wording. */ +export const hopperCallerScanFactsSchema = z.strictObject({ + examined_procedures: z.int().nonnegative(), + procedure_total: z.int().nonnegative().nullable(), + retained_bytes: z.int().nonnegative(), + stop: hopperCallerScanStopSchema, + instructions_complete: z.boolean(), + native_callers_complete: z.boolean().nullable(), +}); + +export type HopperCallerScanFacts = z.infer; +export type HopperCallerScanStop = HopperCallerScanFacts["stop"]; + +/** Counts collected before instruction and native-caller flags are known. */ +export type HopperCallerScanCounts = Omit< + HopperCallerScanFacts, + "instructions_complete" | "native_callers_complete" +>; + +/** Native reply and forward scan facts retained when reconciliation stops. */ +export const hopperCallerReconciliationPartialSchema = z.strictObject({ + kind: z.literal("hopper-caller-reconciliation"), + native_result: jsonValueSchema, + scan: hopperCallerScanFactsSchema.nullable(), + forward_destinations: jsonValueSchema.nullable(), + unread_page: jsonValueSchema.optional(), + coverage: unknownRelationshipCoverageSchema, +}); + +export type HopperCallerReconciliationPartial = z.infer< + typeof hopperCallerReconciliationPartialSchema +>; + +const stopDetail = (stop: HopperCallerScanStop): string => { + switch (stop.code) { + case "complete": + return ""; + case "work_budget": + return `the ${String(stop.budget_ms)} ms reconciliation work budget was reached; set REA_HOPPER_CALLER_SCAN_BUDGET_MS and restart REA to change it`; + case "inventory_changed": + return "the procedure inventory changed during reconciliation"; + case "page_allowance": + return "a single procedure exceeded the 512 KiB page allowance"; + case "retention_budget": + return `the ${String(stop.allowance_bytes)} byte reconciliation retention budget was reached`; + case "cancelled": + return "cancellation was observed after completed scan observations"; + case "page_failure": + return "a later scan page failed"; + case "invalid_page": + return "a scan page reply did not match the scan schema"; + case "invalid_progress": + return "a scan page reported inconsistent progress"; + } +}; + +/** Wording for scan facts. Tests compare this formatter and the facts separately. */ +export const formatHopperCallerScanReason = ( + facts: HopperCallerScanFacts, +): string => { + const total = + facts.procedure_total === null ? "unknown" : String(facts.procedure_total); + const detail = stopDetail(facts.stop); + let reason = `Hopper caller reconciliation examined ${String(facts.examined_procedures)} of ${total} procedures and retained ${String(facts.retained_bytes)} encoded bytes${detail === "" ? "" : `; ${detail}`}. Forward and reverse API coverage and consistency during the live scan remain unknown.`; + if (!facts.instructions_complete) + reason += + " Target instruction enumeration reached the 512 KiB allowance; incoming call-site coverage is incomplete."; + if (facts.native_callers_complete === false) + reason += " Native caller enumeration reached the 512 KiB allowance."; + return reason; +}; diff --git a/src/hopper/HopperCallerReconciliation.test.ts b/src/hopper/HopperCallerReconciliation.test.ts index 288df1c57d..058fcfb9b6 100644 --- a/src/hopper/HopperCallerReconciliation.test.ts +++ b/src/hopper/HopperCallerReconciliation.test.ts @@ -1,8 +1,10 @@ import { expect, it, vi } from "vitest"; -import { ok } from "../domain/result.js"; +import { err, ok } from "../domain/result.js"; import type { HopperClient } from "./HopperClient.js"; import { reconcileHopperCallers } from "./HopperCallerReconciliation.js"; import { AnalysisWorkScope } from "../application/AnalysisWorkScope.js"; +import { formatHopperCallerScanReason } from "../domain/native/hopperCallerReconciliation.js"; +import { AnalysisOutputError } from "../domain/analysisErrorCore.js"; const identity = (address: string) => ({ address, @@ -141,7 +143,17 @@ it("merges paged call sites and pins the selected target", async () => { expect(result.ok).toBe(true); if (!result.ok) return; expect(result.value.result).toMatchObject({ references: [edge] }); - expect(result.value.reason).toContain("65 of 65"); + expect(result.value.scan).toMatchObject({ + examined_procedures: 65, + procedure_total: 65, + stop: { code: "complete" }, + instructions_complete: true, + native_callers_complete: null, + }); + if (result.value.scan === undefined) throw new Error("missing scan facts"); + expect(result.value.reason).toBe( + formatHopperCallerScanReason(result.value.scan), + ); expect(callTool.mock.calls[2]?.[1]).toMatchObject({ start: 64 }); }); it("does not reuse a previous scan", async () => { @@ -204,6 +216,16 @@ it("stops between pages on cancellation", async () => { if (!result.ok) { expect(result.error._tag).toBe("AnalysisCancelledError"); expect(result.error.cause).toBe("test cancellation"); + expect(result.error.partialObservation).toMatchObject({ + kind: "hopper-caller-reconciliation", + native_result: base, + coverage: { status: "unknown" }, + scan: { + examined_procedures: 64, + procedure_total: 65, + stop: { code: "cancelled" }, + }, + }); } expect(callTool).toHaveBeenCalledTimes(1); }); @@ -234,7 +256,59 @@ it("preserves cancellation while reporting the last page", async () => { 250, ); expect(result.ok).toBe(false); - if (!result.ok) expect(result.error.cause).toBe("cancelled in progress"); + if (!result.ok) { + expect(result.error.cause).toBe("cancelled in progress"); + expect(result.error.partialObservation).toMatchObject({ + kind: "hopper-caller-reconciliation", + native_result: base, + coverage: { status: "unknown" }, + scan: { + examined_procedures: 1, + procedure_total: 1, + stop: { code: "cancelled" }, + }, + forward_destinations: [ + expect.objectContaining({ + address: target.address, + callers: [expect.objectContaining({ address: caller.address })], + }), + ], + }); + } +}); +it("retains completed pages when a later scan page fails", async () => { + let pages = 0; + const callTool = vi.fn().mockImplementation((method: string) => { + if (method === "rea_caller_scan_target") + return Promise.resolve(ok(reply(method))); + pages += 1; + if (pages === 1) return Promise.resolve(ok(page(0))); + return Promise.resolve( + err( + new AnalysisOutputError("rea_caller_scan_page", "bridge page failed"), + ), + ); + }); + const result = await reconcileHopperCallers( + { callTool } as unknown as HopperClient, + "procedure_callers", + {}, + base, + undefined, + 250, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.partialObservation).toMatchObject({ + kind: "hopper-caller-reconciliation", + native_result: base, + coverage: { status: "unknown" }, + scan: { + examined_procedures: 64, + procedure_total: 65, + stop: { code: "page_failure" }, + }, + }); }); it("reports a changing inventory as incomplete", async () => { const callTool = vi @@ -257,7 +331,16 @@ it("reports a changing inventory as incomplete", async () => { 250, ); expect(result.ok).toBe(true); - if (result.ok) expect(result.value.reason).toContain("64 of 65"); + if (!result.ok) return; + expect(result.value.scan).toMatchObject({ + examined_procedures: 64, + procedure_total: 65, + stop: { code: "inventory_changed" }, + }); + if (result.value.scan === undefined) throw new Error("missing scan facts"); + expect(result.value.reason).toBe( + formatHopperCallerScanReason(result.value.scan), + ); }); it("retains original callers and reports a procedure that exceeds the page allowance", async () => { @@ -277,8 +360,17 @@ it("retains original callers and reports a procedure that exceeds the page allow expect(result.ok).toBe(true); if (result.ok) { expect(result.value.result).toEqual(["0x1000"]); - expect(result.value.reason).toContain( - "single procedure exceeded the 512 KiB page allowance", + expect(result.value.scan).toMatchObject({ + examined_procedures: 0, + procedure_total: 65, + stop: { + code: "page_allowance", + allowance_bytes: 512 * 1024, + }, + }); + if (result.value.scan === undefined) throw new Error("missing scan facts"); + expect(result.value.reason).toBe( + formatHopperCallerScanReason(result.value.scan), ); } }); @@ -299,11 +391,16 @@ it("reports truncated target instructions and native callers", async () => { ); expect(result.ok).toBe(true); if (result.ok) { - expect(result.value.reason).toContain( - "Target instruction enumeration reached the 512 KiB allowance", - ); - expect(result.value.reason).toContain( - "Native caller enumeration reached the 512 KiB allowance", + expect(result.value.scan).toMatchObject({ + examined_procedures: 0, + procedure_total: 0, + stop: { code: "complete" }, + instructions_complete: false, + native_callers_complete: false, + }); + if (result.value.scan === undefined) throw new Error("missing scan facts"); + expect(result.value.reason).toBe( + formatHopperCallerScanReason(result.value.scan), ); } }); diff --git a/src/hopper/HopperCallerReconciliation.ts b/src/hopper/HopperCallerReconciliation.ts index 9c1cbf2218..22567b289c 100644 --- a/src/hopper/HopperCallerReconciliation.ts +++ b/src/hopper/HopperCallerReconciliation.ts @@ -17,6 +17,16 @@ import { nativeCallClassificationSchema } from "../domain/native/nativeCallRefer import { err, ok, type Result } from "../domain/result.js"; import type { HopperClient } from "./HopperClient.js"; import { analysisClientFailureForOperation } from "../providers/AnalysisClientFailure.js"; +import { + formatHopperCallerScanReason, + hopperCallerReconciliationPartialSchema, + HOPPER_CALLER_PAGE_ALLOWANCE_BYTES, + HOPPER_CALLER_SCAN_RETENTION_BYTES, + type HopperCallerReconciliationPartial, + type HopperCallerScanCounts, + type HopperCallerScanFacts, + type HopperCallerScanStop, +} from "../domain/native/hopperCallerReconciliation.js"; const compactEdgeSchema = z.strictObject({ source_address: z.string(), @@ -59,7 +69,9 @@ const referencesSchema = z.strictObject({ references: z.array(referenceEdgeSchema), }); type Operation = - "procedure_callers" | "procedure_references" | "analyze_function"; + | "procedure_callers" + | "procedure_references" + | "analyze_function"; type Identity = z.infer; type Edge = z.infer; type CompactEdge = z.infer; @@ -67,8 +79,16 @@ type Destination = { callers: Map; references: Array<{ caller: Identity; edge: CompactEdge }>; }; -type Index = { destinations: Map; reason: string }; -const MAX_RETAINED_SCAN_BYTES = 4 * 1024 * 1024; +type Index = { + destinations: Map; + counts: HopperCallerScanCounts; +}; +type ScanFailure = { + readonly error: AnalysisError; + readonly counts: HopperCallerScanCounts; + readonly destinations: Map; + readonly unreadPage?: JsonValue; +}; /** A child bridge request is a phase of the enclosing user operation. */ export function reconciliationChildOptions( @@ -109,13 +129,25 @@ async function reportScanProgress( } } +const scanCounts = ( + stop: HopperCallerScanStop, + examined: number, + procedureTotal: number | undefined, + retainedBytes: number, +): HopperCallerScanCounts => ({ + examined_procedures: examined, + procedure_total: procedureTotal ?? null, + retained_bytes: retainedBytes, + stop, +}); + async function buildIndex( client: HopperClient, operation: Operation, document: JsonValue | undefined, options: ExecutionOptions | undefined, budgetMs: number, -): Promise> { +): Promise> { const destinations = new Map(); const destination = (address: string): Destination => { let value = destinations.get(address); @@ -129,14 +161,25 @@ async function buildIndex( total: number | undefined, bytes = 0; const began = performance.now(); - let stopped = ""; + let stop: HopperCallerScanStop = { code: "complete" }; + const cancelled = (): AnalysisCancelledError => + new AnalysisCancelledError(operation, { cause: options?.signal?.reason }); + const fail = ( + error: AnalysisError, + failureStop: HopperCallerScanStop, + unreadPage?: JsonValue, + ): Result => + err({ + error, + counts: scanCounts(failureStop, start, total, bytes), + destinations, + ...(unreadPage === undefined ? {} : { unreadPage }), + }); do { if (options?.signal?.aborted) - return err( - new AnalysisCancelledError(operation, { cause: options.signal.reason }), - ); + return fail(cancelled(), { code: "cancelled" }); if (start > 0 && performance.now() - began >= budgetMs) { - stopped = `the ${budgetMs} ms reconciliation work budget was reached; set REA_HOPPER_CALLER_SCAN_BUDGET_MS and restart REA to change it`; + stop = { code: "work_budget", budget_ms: budgetMs }; break; } if (start > 0) await reportScanProgress(options, start, total); @@ -160,20 +203,25 @@ async function buildIndex( pageOptions, ); if (!page.ok) - return err( - analysisClientFailureForOperation(page.error, "hopper", operation), - ); - if (options?.signal?.aborted) - return err( - new AnalysisCancelledError(operation, { cause: options.signal.reason }), - ); + return options?.signal?.aborted + ? fail(cancelled(), { code: "cancelled" }) + : fail( + analysisClientFailureForOperation(page.error, "hopper", operation), + { code: "page_failure" }, + ); const decoded = pageSchema.safeParse(page.value); if (!decoded.success) - return err( - new AnalysisOutputError(operation, "Invalid Hopper caller scan page", { - cause: decoded.error, - }), - ); + return options?.signal?.aborted + ? fail(cancelled(), { code: "cancelled" }, page.value) + : fail( + new AnalysisOutputError( + operation, + "Invalid Hopper caller scan page", + { cause: decoded.error }, + ), + { code: "invalid_page" }, + page.value, + ); const value = decoded.data; if ( value.start !== start || @@ -186,24 +234,34 @@ async function buildIndex( value.items.length !== value.next - start || (value.oversized && value.next !== start) ) - return err( - new AnalysisOutputError( - operation, - "Invalid Hopper caller scan progress", - ), - ); + return options?.signal?.aborted + ? fail(cancelled(), { code: "cancelled" }, page.value) + : fail( + new AnalysisOutputError( + operation, + "Invalid Hopper caller scan progress", + ), + { code: "invalid_progress" }, + page.value, + ); if (total !== undefined && value.total !== total) { - stopped = "the procedure inventory changed during reconciliation"; + stop = { code: "inventory_changed" }; break; } total = value.total; if (value.oversized) { - stopped = "a single procedure exceeded the 512 KiB page allowance"; + stop = { + code: "page_allowance", + allowance_bytes: HOPPER_CALLER_PAGE_ALLOWANCE_BYTES, + }; break; } const pageBytes = Buffer.byteLength(JSON.stringify(page.value)); - if (pageBytes > MAX_RETAINED_SCAN_BYTES - bytes) { - stopped = `the ${MAX_RETAINED_SCAN_BYTES} byte reconciliation retention budget was reached`; + if (pageBytes > HOPPER_CALLER_SCAN_RETENTION_BYTES - bytes) { + stop = { + code: "retention_budget", + allowance_bytes: HOPPER_CALLER_SCAN_RETENTION_BYTES, + }; break; } bytes += pageBytes; @@ -218,16 +276,17 @@ async function buildIndex( } } start = value.next; + if (options?.signal?.aborted) + return fail(cancelled(), { code: "cancelled" }); await reportScanProgress(options, start, total); + if (options?.signal?.aborted) + return fail(cancelled(), { code: "cancelled" }); } while (start < (total ?? 0)); await reportScanProgress(options, start, total, true); - if (options?.signal?.aborted) - return err( - new AnalysisCancelledError(operation, { cause: options.signal.reason }), - ); + if (options?.signal?.aborted) return fail(cancelled(), { code: "cancelled" }); return ok({ destinations, - reason: `Hopper caller reconciliation examined ${start} of ${total ?? 0} procedures and retained ${bytes} encoded bytes${stopped ? `; ${stopped}` : ""}. Forward and reverse API coverage and consistency during the live scan remain unknown.`, + counts: scanCounts(stop, start, total, bytes), }); } @@ -240,19 +299,69 @@ export const reconcileHopperCallers = async ( budgetMs: number, ): Promise< Result< - { result: JsonValue; rawResult: JsonValue; reason: string }, + { + result: JsonValue; + rawResult: JsonValue; + reason: string; + scan?: HopperCallerScanFacts; + }, AnalysisError > > => { + const retainNative = ( + error: AnalysisError, + partial: { + readonly scan: HopperCallerScanFacts | null; + readonly destinations: Map | null; + readonly unreadPage?: JsonValue; + }, + ): AnalysisError => { + const observation: HopperCallerReconciliationPartial = { + kind: "hopper-caller-reconciliation", + native_result: original, + scan: partial.scan, + forward_destinations: + partial.destinations === null + ? null + : [...partial.destinations.entries()].map(([address, value]) => ({ + address, + callers: [...value.callers.values()], + references: value.references, + })), + coverage: { + status: "unknown", + reason: + partial.scan === null + ? "Hopper caller reconciliation did not examine the procedure inventory; unexamined procedure relationships are unknown." + : formatHopperCallerScanReason(partial.scan), + limited_directions: + operation === "analyze_function" + ? ["callers", "callees"] + : ["callers"], + }, + ...(partial.unreadPage === undefined + ? {} + : { unread_page: partial.unreadPage }), + }; + error.retainPartialObservation( + hopperCallerReconciliationPartialSchema.parse(observation), + ); + return error; + }; const base = operation === "procedure_callers" ? callersBaseSchema.safeParse(original) : undefined; if (base !== undefined && !base.success) return err( - new AnalysisOutputError(operation, "Invalid Hopper caller observation", { - cause: base.error, - }), + retainNative( + new AnalysisOutputError( + operation, + "Invalid Hopper caller observation", + { cause: base.error }, + ), + { scan: null, destinations: null }, + ), ); const parsed = operation === "procedure_callers" @@ -264,9 +373,14 @@ export const reconcileHopperCallers = async ( : referencesSchema.safeParse(original); if (!parsed.success) return err( - new AnalysisOutputError(operation, "Invalid Hopper caller observation", { - cause: parsed.error, - }), + retainNative( + new AnalysisOutputError( + operation, + "Invalid Hopper caller observation", + { cause: parsed.error }, + ), + { scan: null, destinations: null }, + ), ); const args: Record = {}; if (parameters.document !== undefined) args.document = parameters.document; @@ -284,22 +398,35 @@ export const reconcileHopperCallers = async ( ); if (!targetResponse.ok) return err( - analysisClientFailureForOperation( - targetResponse.error, - "hopper", - operation, + retainNative( + analysisClientFailureForOperation( + targetResponse.error, + "hopper", + operation, + ), + { scan: null, destinations: null }, ), ); if (options?.signal?.aborted) return err( - new AnalysisCancelledError(operation, { cause: options.signal.reason }), + retainNative( + new AnalysisCancelledError(operation, { + cause: options.signal.reason, + }), + { scan: null, destinations: null }, + ), ); const selected = targetSchema.safeParse(targetResponse.value); if (!selected.success) return err( - new AnalysisOutputError(operation, "Invalid Hopper caller scan target", { - cause: selected.error, - }), + retainNative( + new AnalysisOutputError( + operation, + "Invalid Hopper caller scan target", + { cause: selected.error }, + ), + { scan: null, destinations: null }, + ), ); const { target, instructions, instructions_complete } = selected.data; if ( @@ -327,7 +454,24 @@ export const reconcileHopperCallers = async ( options, budgetMs, ); - if (!scanned.ok) return scanned; + if (!scanned.ok) { + const failure = scanned.error; + return err( + retainNative(failure.error, { + scan: { + ...failure.counts, + instructions_complete, + native_callers_complete: base?.success + ? base.data.callers_complete + : null, + }, + destinations: failure.destinations, + ...(failure.unreadPage === undefined + ? {} + : { unreadPage: failure.unreadPage }), + }), + ); + } index = scanned.value; if (options?.workScope !== undefined) { if (indexByDocument === undefined) { @@ -410,6 +554,11 @@ export const reconcileHopperCallers = async ( const facts = referencesSchema.parse(parsed.data); result = { ...facts, references: mergeReferences(facts.references) }; } + const scan: HopperCallerScanFacts = { + ...index.counts, + instructions_complete, + native_callers_complete: base?.success ? base.data.callers_complete : null, + }; return ok({ result, rawResult: { @@ -424,6 +573,7 @@ export const reconcileHopperCallers = async ( "hopper-public-python-api:getAllCalleeProcedures and CallReference", }, }, - reason: `${index.reason}${instructions_complete ? "" : " Target instruction enumeration reached the 512 KiB allowance; incoming call-site coverage is incomplete."}${base?.success && !base.data.callers_complete ? " Native caller enumeration reached the 512 KiB allowance." : ""}`, + scan, + reason: formatHopperCallerScanReason(scan), }); }; diff --git a/src/hopper/HopperProvider.test.ts b/src/hopper/HopperProvider.test.ts index 0dd0826b19..91f00c5633 100644 --- a/src/hopper/HopperProvider.test.ts +++ b/src/hopper/HopperProvider.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it, vi } from "vitest"; import { parseConfig } from "../config/parseConfig.js"; import { silentLogger } from "../logger.js"; -import { ok } from "../domain/result.js"; +import { err, ok } from "../domain/result.js"; +import { AnalysisOutputError } from "../domain/analysisErrorCore.js"; import { HopperClient } from "./HopperClient.js"; import { HopperProvider } from "./HopperProvider.js"; import { isSnapshotCacheable } from "../application/binary/AnalysisSnapshotCache.js"; @@ -257,3 +258,93 @@ describe("Hopper relationship coverage", () => { }, ); }); + +describe("Hopper caller reconciliation failures", () => { + it("keeps the native caller reply when a later scan page fails", async () => { + const config = parseConfig({}); + if (!config.ok) throw config.error; + const identity = (address: string) => ({ + address, + name: address, + symbol: null, + classification: null, + body: { available: false as const, reason: "unknown" }, + }); + const native = { + target: identity("0x1000"), + instructions: ["0x1000"], + instructions_complete: true, + callers: ["0x1111"], + callers_complete: true, + }; + let pages = 0; + const callTool = vi + .spyOn(HopperClient.prototype, "callTool") + .mockImplementation((method) => { + if (method === "rea_caller_base") return Promise.resolve(ok(native)); + if (method !== "rea_caller_scan_page") return Promise.resolve(ok(null)); + pages += 1; + if (pages === 1) + return Promise.resolve( + ok({ + start: 0, + next: 1, + total: 2, + oversized: false, + items: [ + { + caller: identity("0x3000"), + callees: ["0x1000"], + references: [], + }, + ], + }), + ); + return Promise.resolve( + err( + new AnalysisOutputError( + "rea_caller_scan_page", + "bridge page failed", + ), + ), + ); + }); + const client = new HopperProvider( + config.value, + silentLogger, + {}, + "linux", + ).createClient({ + path: "/tmp/fixture.hop", + sha256: "a".repeat(64), + kind: "database", + format: "analysis-database", + }); + try { + const observed = await client.execute("procedure_callers", { + procedure: "0x1000", + }); + expect(observed.ok).toBe(false); + if (observed.ok) return; + expect(observed.error.partialObservation).toMatchObject({ + kind: "hopper-caller-reconciliation", + native_result: native, + coverage: { status: "unknown", limited_directions: ["callers"] }, + scan: { + examined_procedures: 1, + procedure_total: 2, + stop: { code: "page_failure" }, + }, + forward_destinations: [ + expect.objectContaining({ + address: "0x1000", + callers: [expect.objectContaining({ address: "0x3000" })], + }), + ], + }); + } finally { + await client.close(); + callTool.mockRestore(); + } + }); +}); diff --git a/tests/boundary/filesystem/composedWorkflowEvidenceLinks.test.ts b/tests/boundary/filesystem/composedWorkflowEvidenceLinks.test.ts index 120b6c0b88..a7a89571ae 100644 --- a/tests/boundary/filesystem/composedWorkflowEvidenceLinks.test.ts +++ b/tests/boundary/filesystem/composedWorkflowEvidenceLinks.test.ts @@ -292,13 +292,29 @@ it.each(["profileless", "uncacheable"] as const)( {}, ); if (!execution.ok) return execution; - expect(execution.value.evidenceId).toBeUndefined(); + if (mode === "profileless") + expect(execution.value.evidenceId).toBeUndefined(); + else { + expect(execution.value.evidenceId).toEqual(expect.any(String)); + expect(execution.value.evidenceId).not.toBe(spoofedEvidenceId); + } return ok(execution.value.result); }, ); if (!composed.ok) throw composed.error; - expect(composed.value.evidenceLinks).toBeUndefined(); - expect(fixture.session.exportEvidenceBundle().records).toEqual([]); + if (mode === "profileless") { + expect(composed.value.evidenceLinks).toBeUndefined(); + expect(fixture.session.exportEvidenceBundle().records).toEqual([]); + } else { + const evidenceId = composed.value.evidenceLinks?.[0]; + expect(evidenceId).toEqual(expect.any(String)); + expect(evidenceId).not.toBe(spoofedEvidenceId); + expect( + fixture.session + .exportEvidenceBundle() + .records.map((record) => record.evidence_id), + ).toEqual([evidenceId]); + } }, ); diff --git a/tests/composition/analysis-sessions/binarySession.test.ts b/tests/composition/analysis-sessions/binarySession.test.ts index 535144585b..c1e56cac2f 100644 --- a/tests/composition/analysis-sessions/binarySession.test.ts +++ b/tests/composition/analysis-sessions/binarySession.test.ts @@ -1076,12 +1076,22 @@ describe("snapshot cache eligibility", () => { const parameters: Readonly> = live ? { address: "0x1000", document: "main" } : {}; + const evidenceIds: Array = []; for (let attempt = 0; attempt < 2; attempt += 1) { const result = await session.execute("address_name", parameters); - expect(result).toMatchObject({ ok: true }); - if (result.ok) expect(result.value.evidenceId).toBeUndefined(); + if (!result.ok) throw result.error; + evidenceIds.push(result.value.evidenceId); + } + const records = session.exportEvidenceBundle().records; + if (live) { + expect(evidenceIds).toEqual([expect.any(String), evidenceIds[0]]); + expect(records.map((record) => record.evidence_id)).toEqual( + expect.arrayContaining([source.evidence_id, evidenceIds[0]]), + ); + } else { + expect(evidenceIds).toEqual([undefined, undefined]); + expect(records).toEqual([source]); } - expect(session.exportEvidenceBundle().records).toEqual([source]); expect(session.exportAnalysisSnapshot()).toMatchObject({ ok: true, value: { entries: [] }, @@ -1122,20 +1132,20 @@ describe("snapshot cache eligibility", () => { await session.close(); } }); +}); - it("does not snapshot reads that depend on the provider cursor", async () => { - const [first] = await createBinarySessionTargets(); - const calls: string[] = []; - const session = createTestBinarySession(createCacheProvider(calls)); - await openAndExecuteAddressName(session, first, {}); - expect((await session.execute("address_name", {})).ok).toBe(true); - expect(calls).toEqual(["address_name", "address_name"]); - expect(session.exportAnalysisSnapshot()).toMatchObject({ - ok: true, - value: { entries: [] }, - }); - await session.close(); +it("does not snapshot reads that depend on the provider cursor", async () => { + const [first] = await createBinarySessionTargets(); + const calls: string[] = []; + const session = createTestBinarySession(createCacheProvider(calls)); + await openAndExecuteAddressName(session, first, {}); + expect((await session.execute("address_name", {})).ok).toBe(true); + expect(calls).toEqual(["address_name", "address_name"]); + expect(session.exportAnalysisSnapshot()).toMatchObject({ + ok: true, + value: { entries: [] }, }); + await session.close(); }); describe("provider health and availability", () => {