fix(android): preserve typed JVM memory failures during loading

Translate actual OutOfMemoryError at the bridge boundary before upstream stringifies it. Preserve observed heap bytes, missing reasons, coroutine context and cancellation; retain SessionHolder ownership and load options. Restrict heap-specific remediation to known heap reasons and parse named bridge failure contracts once.

Coordinate bridge protocol v4, clarify doctor configuration versus target capacity, and preserve the memory primary across cleanup failure/retry. Verify controlled immediate/late/inline continuation outcomes with the pinned JAR and real JDK, focused tests, and tiny real CLI/MCP method workflows.

Actual heap exhaustion, the reported APK, pre-handshake failures, Linux and Windows remain unverified; no stress or profiling run.

Fixes #3517
This commit is contained in:
morluto committed 2026-10-18 00:48:12 +08:00
1 parent 9fa6d2b254
commit 3fa972af7c
11 files changed
+384 -19

No files matched your search

+1 -1
View File
@@ -79,7 +79,7 @@ const configurationCheck = (
name: "configuration",
ok: true,
code: null,
detail: `JADX MCP bridge JAR validated at ${jarPath}; its path is recorded for agent registrations.`,
detail: `JADX MCP bridge JAR validated at ${jarPath}; its path is recorded for agent registrations. This checks configuration, not whether a selected APK fits in the worker's memory.`,
remediation: null,
classification: "config_drift",
...(typeof operationTimeout === "number"
+27 -1
View File
@@ -5,12 +5,38 @@ import type { AndroidOperation } from "../domain/android/androidAnalysis.js";
import { dexDescriptorSchema } from "../domain/android/androidAnalysis.js";
const count = z.number().int().nonnegative();
/** Typed allocation failures authored before upstream erases the JVM exception. */
const jadxMemoryFailureSchema = z.strictObject({
kind: z.literal("jvm-memory-failure"),
memory_area: z.enum(["heap", "unknown"]),
exception_type: z.literal("java.lang.OutOfMemoryError"),
message: z.string().optional(),
max_heap_bytes: z.number().int().positive().safe(),
});
/** Selector failures authored by REA's bridge, separate from arbitrary engine errors. */
export const jadxInputFailureSchema = z.strictObject({
const jadxInputFailureSchema = z.strictObject({
kind: z.literal("invalid-input"),
field: z.enum(["class_name", "method_name", "overload_index"]),
message: z.string().min(1),
});
const bridgeFailureSchema = z.discriminatedUnion("kind", [
jadxInputFailureSchema,
jadxMemoryFailureSchema,
]);
/** Arbitrary upstream failure text stays text unless it matches a named bridge contract. */
export const parseJadxFailure = (
text: string,
): z.infer<typeof bridgeFailureSchema> | undefined => {
let candidate: unknown;
try {
candidate = JSON.parse(text);
} catch {
return undefined;
}
const parsed = bridgeFailureSchema.safeParse(candidate);
return parsed.success ? parsed.data : undefined;
};
/** Values observed inside the exact JVM producing the analysis. */
export const jadxRuntimeSchema = z.object({
engine_reported_version: z.string().min(1),
+1 -1
View File
@@ -9,7 +9,7 @@ export const JADX_RELEASE = Object.freeze({
/** Identity of REA's metadata bridge, distinct from the producing engine. */
export const JADX_BRIDGE_IDENTITY = Object.freeze({
name: "rea-jadx-bridge",
version: "3",
version: "4",
});
/** Remediation when `REA_JADX_MCP_JAR` is missing or not an absolute path. */
+31 -8
View File
@@ -9,6 +9,7 @@ import {
AnalysisCapabilityUnavailableError,
AnalysisInputError,
AnalysisOutputError,
AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
@@ -18,7 +19,7 @@ import { analyzeJadxRequest, type JadxToolPort } from "./JadxAnalysis.js";
import { JadxMcpTransport, type JadxLauncher } from "./JadxMcpTransport.js";
import {
jadxLoadSchema,
jadxInputFailureSchema,
parseJadxFailure,
jadxRuntimeSchema,
parseJadxEnvelope,
parseJadxJson,
@@ -225,16 +226,38 @@ export class JadxSession {
this.#observedCalls.push(call);
const envelope = parseJadxEnvelope(response, request.operation);
if (envelope.failed) {
if (envelope.text.startsWith("{")) {
const failure = jadxInputFailureSchema.safeParse(
parseJadxJson(envelope.text, request.operation),
);
if (failure.success)
const failure = parseJadxFailure(envelope.text);
if (failure !== undefined) {
if (name === "load_apk" && failure.kind === "jvm-memory-failure")
throw new AnalysisResourceConstraintError(
request.operation,
"memory",
`The JADX JVM could not allocate memory during ${name}${failure.message ? `: ${failure.message}` : ""}`,
{
observed_max_heap_bytes: failure.max_heap_bytes,
memory_area: failure.memory_area,
},
{
retryable: false,
remediationAction:
failure.memory_area === "heap"
? "Increase REA_JADX_HEAP_MIB (MiB) in the environment that starts REA, within the host or container memory budget. Review JAVA_TOOL_OPTIONS and other JVM heap overrides. The observed heap is this run's limit, not the heap the APK requires."
: "Review the JVM allocation failure and the host or container memory limits. The observed maximum heap does not establish which memory region was exhausted; increasing heap alone may not help.",
diagnostics: {
upstream_operation: name,
reason: failure.message ?? null,
exception_type: failure.exception_type,
target_path: target.path,
target_sha256: target.sha256,
},
},
);
if (failure.kind === "invalid-input")
throw new AnalysisInputError(request.operation, undefined, [
{
path: [failure.data.field],
path: [failure.field],
reason: "invalid_value",
message: failure.data.message,
message: failure.message,
},
]);
}