Files
rea/src/application/native/NativeValueTrace.ts
T
Alex Gorevski 087813fb88 fix(cancellation): preserve caller abort reasons through late finalization (#6143)
* fix(cancellation): preserve caller abort reasons through late finalization

Caller aborts that arrived during final provider, cleanup, or protocol
handling were dropped or replaced by I/O, cleanup, or success results.
Each affected path now samples the caller signal after its owner has
settled and returns AnalysisCancelledError carrying the exact
signal.reason, keeping any completed partial Evidence or cleanup state.

- Web script export: keep the exact reason for an interrupted output
  write, and honor a cancellation fact recorded before a retried
  capture close succeeds.
- Native value tracing and Hopper regex search: check the signal after
  the final provider reply or regex worker settles.
- Apple asset catalog: stop assetutil with SIGTERM so cancellation
  settles only after the helper exits.
- CDP observation: end an armed window on a fatal protocol failure.
- ADB bugreport: prefer cancellation over the staging-removal output
  error when the caller aborted.
- JavaScript recovery: check the signal after provider success.
- export-web-scripts CLI: run under command cancellation and forward
  the signal into the export workflow.
- Objdump and Rizin CLI: report cancellation after cleanup completes.
- Binary session: keep cancellation primary when previous-client
  retirement fails after abort.

Fixes morluto/rea#6042
Fixes morluto/rea#6043
Fixes morluto/rea#6048
Fixes morluto/rea#6049
Fixes morluto/rea#6057
Fixes morluto/rea#6060
Fixes morluto/rea#6078
Fixes morluto/rea#6080
Fixes morluto/rea#6108
Fixes morluto/rea#6111
Fixes morluto/rea#6115

* fix(cancellation): keep caller reasons through export rollback and commit

- Rollback failure after a caller abort now returns AnalysisCancelledError
  with the exact signal.reason, attaching the rollback failure as cleanup
  and retaining any published result as partial observation.
- A capture close retry that succeeds after abort returns cancellation
  instead of the stale cleanup diagnostic.
- An abort that lands during the final output commit returns cancellation
  carrying the completed manifest as partial observation.
- Apple assetutil stop test: abort and await the run in finally so a
  timed-out wait does not leave the child running, and skip on Windows
  where the JavaScript SIGTERM handler cannot run.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 35b247e9-c793-4b8f-979a-807c27112cf5

* fix(cancellation): bind export cancellations to cleanup and caller reasons

- A rollback-failure cancellation is bound to the retained cleanup lease,
  so a later successful close clears cleanup_incomplete instead of
  reporting a residual directory that no longer exists.
- mapExportFailure wraps the caller's own AnalysisCancelledError reason
  under export_web_scripts rather than returning it as the export's error.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 35b247e9-c793-4b8f-979a-807c27112cf5

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
Copilot-Session: 35b247e9-c793-4b8f-979a-807c27112cf5
2026-10-22 04:15:16 +08:00

457 lines
16 KiB
TypeScript

import { functionDossierSchema } from "../../domain/hopperValues.js";
import { nativeCallTargetsSchema } from "../../domain/native/nativeInstruction.js";
import {
nativeValueTraceInputSchema,
nativeValueTraceSchema,
} from "../../domain/native/nativeValueTrace.js";
import { createEvidence } from "../../domain/evidence.js";
import {
AnalysisCancelledError,
AnalysisOutputError,
} from "../../domain/analysisErrorCore.js";
import { err, ok } from "../../domain/result.js";
import type { AnalysisOperationPort } from "../AnalysisProvider.js";
import type { EnhancedResult } from "../EnhancedToolTypes.js";
import { jsonParts } from "../../domain/jsonSerialization.js";
import {
renderSourceLimitations,
type SourceLimitation,
} from "../SourceLimitations.js";
const NODE_PAYLOAD_BYTES = 8 * 1024 * 1024;
/** Compose bounded high-p-code def-use graphs across exactly referenced call destinations. */
export const traceNativeValues = async (
analysis: AnalysisOperationPort,
parameters: unknown,
signal?: AbortSignal,
): EnhancedResult => {
const input = nativeValueTraceInputSchema.parse(parameters);
const nodes: typeof nativeValueTraceSchema._output.nodes = [];
const edges: typeof nativeValueTraceSchema._output.edges = [];
const procedures: typeof nativeValueTraceSchema._output.procedures = [];
const unknowns: typeof nativeValueTraceSchema._output.unknowns = [];
const sourceLimitations: SourceLimitation[] = [];
const callBindings: {
caller: string;
callee: string;
call: string;
argument_count: number;
output: boolean;
evidence_id: string;
}[] = [];
const pending = [{ procedure: input.procedure, depth: 0 }];
const attempted = new Set<string>();
const visited = new Set<string>();
let targetSha256: string | undefined;
let truncated = false;
let decompilations = 0;
let callSites = 0;
let bytes = 0;
let nodeBudgetReached = false;
const retainNode = (value: (typeof nodes)[number]): boolean => {
if (nodeBudgetReached) return false;
if (nodes.length >= input.max_nodes) {
truncated = true;
nodeBudgetReached = true;
return false;
}
let size = 0;
// Reuse bounded JSON encoding to count escapes and UTF-8 expansion
// before allocating a complete serialized node that might not fit.
for (const part of jsonParts(value)) {
size += Buffer.byteLength(part);
if (size > NODE_PAYLOAD_BYTES - bytes) {
truncated = true;
nodeBudgetReached = true;
return false;
}
}
bytes += size;
nodes.push(value);
return true;
};
while (pending.length > 0) {
if (signal?.aborted)
return err(new AnalysisCancelledError("trace_native_values"));
const next = pending.shift();
if (
next === undefined ||
attempted.has(next.procedure) ||
visited.has(next.procedure)
)
continue;
if (decompilations >= input.max_functions) {
truncated = true;
unknowns.push({
procedure: next.procedure,
address: null,
reason: "decompilation budget reached",
});
break;
}
// An exact selector is attempted once per trace, even on failure. Only
// successful dossiers establish canonical procedure identity in visited.
attempted.add(next.procedure);
const execution = await analysis.execute(
"analyze_function",
{ procedure: next.procedure },
{
...(signal === undefined ? {} : { signal }),
...(next.depth > 0 ? { selectorOrigin: "provider" as const } : {}),
},
);
decompilations++;
if (!execution.ok) {
if (execution.error._tag === "AnalysisCancelledError")
return err(execution.error);
unknowns.push({
procedure: next.procedure,
address: null,
reason: execution.error.message,
});
continue;
}
const parsed = functionDossierSchema.safeParse(execution.value.result);
if (!parsed.success || execution.value.subject == null)
return err(
new AnalysisOutputError(
"trace_native_values",
"Function dossier lacks validated facts or artifact identity",
),
);
const dossier = parsed.data;
const address = dossier.procedure.address;
if (visited.has(address)) continue;
visited.add(next.procedure);
visited.add(address);
targetSha256 ??= execution.value.subject.sha256;
if (execution.value.subject.sha256 !== targetSha256)
return err(
new AnalysisOutputError(
"trace_native_values",
"Cross-function target digest mismatch",
),
);
const evidence = createEvidence(
execution.value.subject,
execution.value.provider,
{
operation: "analyze_function",
parameters: { procedure: next.procedure },
result: execution.value.result,
rawResult: execution.value.rawResult,
limitations: execution.value.limitations,
locations: execution.value.locations,
...(execution.value.analysisProfile === undefined
? {}
: { analysisProfile: execution.value.analysisProfile }),
},
);
const evidenceId = evidence.evidence_id;
procedures.push({
address,
depth: next.depth,
evidence_id: evidenceId,
provider: execution.value.provider.id,
analysis_profile_digest: execution.value.analysisProfile?.digest ?? null,
});
const flow = dossier.native_value_flow;
if (flow === null || !flow.available) {
unknowns.push({
procedure: address,
address: null,
reason: flow?.reason ?? "Provider has no native def-use model",
});
continue;
}
if (flow.truncated) {
truncated = true;
unknowns.push({
procedure: address,
address: null,
reason: "Provider p-code was truncated",
});
}
const ids = new Set<string>();
// Keep generated parameter identity independent of which operations fit
// the budget, while preserving every provider-supplied operation ID.
const operationIds = new Set(flow.operations.map(({ id }) => id));
const parameterIds = new Map<number, string>();
const qualified = (id: string) => `${address}/${id}`;
for (const operation of flow.operations) {
if (
operation.block_membership === "detached" ||
(operation.block_membership === "unavailable" && operation.is_dead)
)
continue;
const node = {
id: qualified(operation.id),
procedure: address,
evidence_id: evidenceId,
operation,
kind: "operation" as const,
parameter: null,
};
if (!retainNode(node)) break;
ids.add(operation.id);
}
for (const parameter of flow.parameters) {
const parameterId = `parameter:${parameter.ordinal}`;
let localId = parameterId;
for (let suffix = 1; operationIds.has(localId); suffix++)
localId = `${parameterId}#${suffix}`;
const id = qualified(localId);
if (
!retainNode({
id,
procedure: address,
evidence_id: evidenceId,
kind: "parameter",
operation: null,
parameter,
})
)
break;
parameterIds.set(parameter.ordinal, id);
}
for (const use of flow.parameter_uses) {
const source = parameterIds.get(use.ordinal);
if (!ids.has(use.use) || source === undefined) continue;
if (edges.length >= input.max_edges) {
truncated = true;
break;
}
edges.push({
source,
target: qualified(use.use),
input_index: use.input_index,
kind: "parameter-use",
status: "derived",
evidence_id: evidenceId,
});
}
for (const edge of flow.def_use) {
if (!ids.has(edge.definition) || !ids.has(edge.use)) continue;
if (edges.length >= input.max_edges) {
truncated = true;
break;
}
edges.push({
source: qualified(edge.definition),
target: qualified(edge.use),
input_index: edge.input_index,
kind: "def-use",
status: "derived",
evidence_id: evidenceId,
});
}
for (const operation of flow.operations) {
if (
!ids.has(operation.id) ||
!["CALL", "CALLIND"].includes(operation.opcode)
)
continue;
if (callSites >= input.max_call_sites) {
truncated = true;
unknowns.push({
procedure: address,
address: operation.address,
reason: "Call-site resolution budget reached",
});
break;
}
callSites++;
const resolved = await analysis.execute(
"resolve_native_call_targets",
{ address: operation.address },
signal === undefined ? {} : { signal },
);
if (!resolved.ok) {
if (resolved.error._tag === "AnalysisCancelledError")
return err(resolved.error);
unknowns.push({
procedure: address,
address: operation.address,
reason: resolved.error.message,
});
continue;
}
const targets = nativeCallTargetsSchema.safeParse(resolved.value.result);
if (!targets.success)
return err(
new AnalysisOutputError(
"resolve_native_call_targets",
"Invalid static call resolution",
),
);
sourceLimitations.push(
...targets.data.limitations.map((limitation) => ({
provider: resolved.value.provider,
operation: "resolve_native_call_targets" as const,
limitation,
})),
);
if (targets.data.targets.length === 0)
unknowns.push({
procedure: address,
address: operation.address,
reason: `Call target ${targets.data.status}`,
});
for (const target of targets.data.targets) {
if (edges.length >= input.max_edges) {
truncated = true;
break;
}
const destination = target.procedure ?? target.address;
edges.push({
source: qualified(operation.id),
target: destination,
input_index: null,
kind: "call",
status: target.status,
evidence_id: evidenceId,
});
if (target.status !== "candidate")
callBindings.push({
caller: address,
callee: destination,
call: qualified(operation.id),
argument_count: Math.max(0, operation.inputs.length - 1),
output: operation.output !== null,
evidence_id: evidenceId,
});
if (next.depth < input.max_depth && target.status !== "candidate")
pending.push({ procedure: destination, depth: next.depth + 1 });
else if (!visited.has(destination)) {
truncated = true;
unknowns.push({
procedure: address,
address: operation.address,
reason:
target.status === "candidate"
? "Ambiguous call candidates were not traversed"
: "Call depth budget reached",
});
}
}
}
if (
truncated &&
(nodeBudgetReached ||
nodes.length >= input.max_nodes ||
edges.length >= input.max_edges ||
bytes >= NODE_PAYLOAD_BYTES)
)
break;
}
if (targetSha256 === undefined)
return err(
new AnalysisOutputError(
"trace_native_values",
"No seed function could be analyzed",
),
);
const parametersByProcedure = new Map<string, typeof nodes>();
const returnsByProcedure = new Map<string, typeof nodes>();
for (const node of nodes) {
if (node.kind === "parameter") {
const parameters = parametersByProcedure.get(node.procedure) ?? [];
parameters.push(node);
parametersByProcedure.set(node.procedure, parameters);
} else if (
node.operation?.opcode === "RETURN" &&
node.operation.inputs.length > 1
) {
const returns = returnsByProcedure.get(node.procedure) ?? [];
returns.push(node);
returnsByProcedure.set(node.procedure, returns);
}
}
for (const binding of callBindings) {
const parameters = parametersByProcedure.get(binding.callee) ?? [];
if (parameters.length === 0)
unknowns.push({
procedure: binding.caller,
address: null,
reason: `Parameter bindings for ${binding.callee} are unavailable or omitted`,
});
for (const node of parameters) {
const ordinal = node.parameter?.ordinal;
if (ordinal === undefined || ordinal >= binding.argument_count) {
unknowns.push({
procedure: binding.caller,
address: null,
reason: `Argument ${ordinal ?? "unknown"} for ${binding.callee} was not recovered`,
});
continue;
}
if (edges.length >= input.max_edges) {
truncated = true;
break;
}
edges.push({
source: binding.call,
target: node.id,
input_index: ordinal + 1,
kind: "argument-binding",
status: "derived",
evidence_id: binding.evidence_id,
});
}
if (binding.output)
for (const node of returnsByProcedure.get(binding.callee) ?? []) {
if (edges.length >= input.max_edges) {
truncated = true;
break;
}
edges.push({
source: node.id,
target: binding.call,
input_index: 1,
kind: "return-binding",
status: "derived",
evidence_id: node.evidence_id,
});
}
}
const page = nodes.slice(input.offset, input.offset + input.limit);
const pageIds = new Set(page.map(({ id }) => id));
const result = nativeValueTraceSchema.parse({
seed: input.procedure,
target_sha256: targetSha256,
nodes: page,
edges: edges.filter(({ source }) => pageIds.has(source)),
procedures,
unknowns,
total_nodes: nodes.length,
total_edges: edges.length,
decompilations,
next_offset:
input.offset + page.length < nodes.length
? input.offset + page.length
: null,
truncated: truncated || page.length !== nodes.length,
limitations: [
"Static decompiler def-use graphs cover recovered block members; when membership is unavailable they exclude operations marked dead. Logical CALL argument positions bind to recovered HighParam ordinals and RETURN values bind to recovered CALL outputs; these are decompiler-derived dependencies, not original source or runtime observations. Missing parameters, variadic arguments and alias dependencies remain unknown.",
"LOAD and STORE are memory operations; object fields, persistent state and RNG semantics are not inferred from names or pseudocode.",
"Traversal caps depth, decompilations, call-site resolutions, nodes, edges and node payloads (8388608 bytes, 8 MiB). This release has no runtime override for the 8388608-byte node-payload allowance. Complete serialized output is bounded to 33554432 bytes (32 MiB) and that output allowance has no runtime override. Pagination reruns analysis and returns edges whose source is on the node page; endpoints outside the page retain stable identities.",
...renderSourceLimitations(sourceLimitations),
],
});
if (Buffer.byteLength(JSON.stringify(result)) > 32 * 1024 * 1024)
return err(
new AnalysisOutputError(
"trace_native_values",
"Graph output exceeds 33554432 bytes (32 MiB). This release has no runtime override. Lower max_nodes, max_edges, or the page limit.",
),
);
if (signal?.aborted)
return err(
new AnalysisCancelledError("trace_native_values", {
cause: signal.reason,
}),
);
return ok(result);
};