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.
This commit is contained in:
Cursor Agent committed 2026-10-29 07:47:18 +00:00
1 parent 057370657d
commit 0441f646ca
11 files changed
+575 -92

No files matched your search

+1 -1
View File
@@ -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
+5 -2
View File
@@ -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,
+13 -5
View File
@@ -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();
});
@@ -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;
+2
View File
@@ -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
@@ -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<typeof hopperCallerScanFactsSchema>;
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;
};
+108 -11
View File
@@ -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),
);
}
});
+203 -53
View File
@@ -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<typeof procedureIdentitySchema>;
type Edge = z.infer<typeof referenceEdgeSchema>;
type CompactEdge = z.infer<typeof compactEdgeSchema>;
@@ -67,8 +79,16 @@ type Destination = {
callers: Map<string, Identity>;
references: Array<{ caller: Identity; edge: CompactEdge }>;
};
type Index = { destinations: Map<string, Destination>; reason: string };
const MAX_RETAINED_SCAN_BYTES = 4 * 1024 * 1024;
type Index = {
destinations: Map<string, Destination>;
counts: HopperCallerScanCounts;
};
type ScanFailure = {
readonly error: AnalysisError;
readonly counts: HopperCallerScanCounts;
readonly destinations: Map<string, Destination>;
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<Result<Index, AnalysisError>> {
): Promise<Result<Index, ScanFailure>> {
const destinations = new Map<string, Destination>();
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<Index, ScanFailure> =>
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<string, Destination> | 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<string, JsonValue> = {};
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),
});
};
+92 -1
View File
@@ -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();
}
});
});
@@ -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]);
}
},
);
@@ -1076,12 +1076,22 @@ describe("snapshot cache eligibility", () => {
const parameters: Readonly<Record<string, JsonValue>> = live
? { address: "0x1000", document: "main" }
: {};
const evidenceIds: Array<string | undefined> = [];
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", () => {