From 3fa972af7c7cb6cf014ffc426bdf7130dc6f2fd2 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:48:12 +0800 Subject: [PATCH] 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 --- bridge/android/ReaJadxBridge.java | 93 +++++++++++++++++- docs/android-analysis.md | 10 +- scripts/verify/android/metadata.mjs | 4 + src/android/JadxDoctor.ts | 2 +- src/android/JadxProtocol.ts | 28 +++++- src/android/JadxRelease.ts | 2 +- src/android/JadxSession.ts | 39 ++++++-- tests/boundary/android/jadxProtocol.test.ts | 24 +++++ .../boundary/process/jadxIntegration.test.ts | 94 ++++++++++++++++++- .../android/ReaJadxMetadataRegression.java | 86 +++++++++++++++++ tests/fixtures/android/jadx-mcp.mjs | 21 ++++- 11 files changed, 384 insertions(+), 19 deletions(-) diff --git a/bridge/android/ReaJadxBridge.java b/bridge/android/ReaJadxBridge.java index f2686d47c1..563ecce16a 100644 --- a/bridge/android/ReaJadxBridge.java +++ b/bridge/android/ReaJadxBridge.java @@ -15,7 +15,11 @@ import java.util.*; import java.util.concurrent.CountDownLatch; import java.util.stream.Collectors; import kotlin.Unit; +import kotlin.ResultKt; +import kotlin.coroutines.Continuation; +import kotlin.coroutines.CoroutineContext; import kotlin.coroutines.EmptyCoroutineContext; +import kotlin.coroutines.intrinsics.IntrinsicsKt; import kotlinx.coroutines.BuildersKt; import kotlinx.io.CoreKt; import kotlinx.io.JvmCoreKt; @@ -44,6 +48,84 @@ class ReaJadxBridge { private static CallToolResult json(Object value) { return text(JSON.toJson(value), false); } + /** Translate the typed load outcome before upstream drops its exception identity. */ + static Object loadResult(Object result) { + try { ResultKt.throwOnFailure(result); } + catch (Throwable failure) { + if (failure instanceof java.util.concurrent.CancellationException + || failure instanceof Error && !(failure instanceof OutOfMemoryError)) return result; + if (failure instanceof OutOfMemoryError) { + String reason = failure.getMessage(); + boolean heap = "Java heap space".equals(reason) || "GC overhead limit exceeded".equals(reason); + Map reply = new LinkedHashMap<>(); + reply.put("kind", "jvm-memory-failure"); + reply.put("memory_area", heap ? "heap" : "unknown"); + reply.put("exception_type", "java.lang.OutOfMemoryError"); + if (reason != null) reply.put("message", reason); + reply.put("max_heap_bytes", Runtime.getRuntime().maxMemory()); + return text(JSON.toJson(reply), true); + } + return text("load failed: " + failure.getMessage(), true); + } + SessionHolder.LoadResult loaded = (SessionHolder.LoadResult) result; + Map reply = new LinkedHashMap<>(); + reply.put("state", "LOADED"); + reply.put("apk_path", loaded.getApkPath()); + reply.put("class_count", loaded.getClassCount()); + reply.put("raw_class_count", loaded.getRawClassCount()); + reply.put("resource_count", loaded.getResourceCount()); + reply.put("load_duration_ms", loaded.getLoadDurationMs()); + reply.put("decompile_timeout_ms", loaded.getDecompileTimeoutMs()); + reply.put("threads", loaded.getThreads()); + reply.put("code_cache_size", loaded.getCodeCacheSize()); + reply.put("resources", loaded.getResourceMode()); + if (!loaded.getIncludePackages().isEmpty()) reply.put("include_packages", loaded.getIncludePackages()); + if (!loaded.getExcludePackages().isEmpty()) reply.put("exclude_packages", loaded.getExcludePackages()); + return json(reply); + } + + private Object load(kotlinx.serialization.json.JsonObject arguments, SessionConfig defaults, + Continuation continuation) { + JsonObject args = JsonParser.parseString(arguments.toString()).getAsJsonObject(); + String path = string(args, "path"); + SessionConfig options = defaults.withLoadOverrides( + optionalString(args, "include_packages"), optionalString(args, "exclude_packages"), + args.has("threads") ? integer(args, "threads", 1) : null, + args.has("code_cache_size") ? integer(args, "code_cache_size", 64) : null, + optionalString(args, "resources")); + return loadCall(next -> holder.load(path, options, next), continuation); + } + + static Object loadCall(kotlin.jvm.functions.Function1, Object> load, + Continuation continuation) { + var forwarded = new java.util.concurrent.atomic.AtomicBoolean(); + Continuation resumed = new Continuation<>() { + public CoroutineContext getContext() { return continuation.getContext(); } + public void resumeWith(Object result) { + if (!forwarded.compareAndSet(false, true)) throw new IllegalStateException("Load resumed twice"); + Object translated; + try { translated = loadResult(result); } + catch (Throwable failure) { translated = ResultKt.createFailure(failure); } + // Keep the consumer outside the translation catch, including inline completion. + continuation.resumeWith(translated); + } + }; + Object result; + try { result = load.invoke(resumed); } + catch (Throwable failure) { + if (forwarded.get()) ResultKt.throwOnFailure(ResultKt.createFailure(failure)); + result = ResultKt.createFailure(failure); + } + if (result == IntrinsicsKt.getCOROUTINE_SUSPENDED()) return result; + Object translated = loadResult(result); + ResultKt.throwOnFailure(translated); + return translated; + } + + private static String optionalString(JsonObject args, String name) { + return args.has(name) ? string(args, name) : null; + } + private static String string(JsonObject args, String name) { JsonElement value = args.get(name); if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) @@ -341,11 +423,12 @@ class ReaJadxBridge { public static void main(String[] args) throws Exception { PrintStream protocolOut = System.out; System.setOut(System.err); - SessionHolder holder = new SessionHolder(new SessionConfig( - 1048576, 0, 90000L, 1, List.of(), List.of(), 64, ResourceMode.FULL)); + SessionConfig defaults = new SessionConfig( + 1048576, 0, 90000L, 1, List.of(), List.of(), 64, ResourceMode.FULL); + SessionHolder holder = new SessionHolder(defaults); ServerCapabilities capabilities = new ServerCapabilities( new ServerCapabilities.Tools(null), null, null, null, null, null, null, Map.of()); - Server server = new Server(new Implementation("rea-jadx-bridge", "3", null, null, null), + Server server = new Server(new Implementation("rea-jadx-bridge", "4", null, null, null), new ServerOptions(capabilities), "", ignored -> Unit.INSTANCE); SessionToolsKt.registerSessionTools(server, holder); ManifestToolsKt.registerManifestTools(server, holder); @@ -353,6 +436,10 @@ class ReaJadxBridge { MethodToolsKt.registerMethodTools(server, holder); XrefToolsKt.registerXrefTools(server, holder); ReaJadxBridge bridge = new ReaJadxBridge(holder); + Tool loadTool = server.getTools().get("load_apk").getTool(); + server.removeTool("load_apk"); + server.addTool(loadTool, (connection, request, continuation) -> + bridge.load(request.getArguments(), defaults, continuation)); Set consumed = Set.of("load_apk", "get_app_info", "get_android_manifest", "list_classes", "get_class_summary", "get_method_body", "get_xrefs_to_class", "get_xrefs_to_method"); diff --git a/docs/android-analysis.md b/docs/android-analysis.md index d3907a762c..49642d195f 100644 --- a/docs/android-analysis.md +++ b/docs/android-analysis.md @@ -317,7 +317,15 @@ settings. Evidence reports the worker's observed `Runtime.maxMemory()` rounded down to MiB and retains exact bytes, Java version and visible processors in its raw runtime response. The observed allocatable heap can differ from configured `-Xmx`, especially with Serial GC. Heap is not a total RSS cap: the JVM also -uses native memory. Independent REA instances have independent queues. Whole +uses native memory. A typed JVM memory allocation failure during APK loading +reports `resource_constraint` with the observed maximum heap in bytes. Known +heap failures name `REA_JADX_HEAP_MIB` in remediation; other memory regions +remain unknown. A healthy doctor configuration check does not establish that +any particular APK fits. Failures before the bridge can report a typed result +and upstream errors from other operations retain their existing diagnostics; +error text alone does not establish memory exhaustion. + +Independent REA instances have independent queues. Whole operations have a **120-second** execution deadline by default, starting after they reach the queue's front. For a known slow cold load, such as a large multidex APK, select another finite deadline: diff --git a/scripts/verify/android/metadata.mjs b/scripts/verify/android/metadata.mjs index 8693b2be31..8f2a20e5f4 100644 --- a/scripts/verify/android/metadata.mjs +++ b/scripts/verify/android/metadata.mjs @@ -65,6 +65,10 @@ export const verifyAndroidMetadata = async ({ ], { timeout: 120_000, maxBuffer: 8 * 1024 * 1024 }, ); + assert.match( + result.stdout, + /LOAD_FAILURE_REGRESSION_PASS typed=true decoy=true suspension=true cancellation=true/u, + ); assert.match( result.stdout, /METADATA_REGRESSION_PASS classes=\d+ overloads=3/u, diff --git a/src/android/JadxDoctor.ts b/src/android/JadxDoctor.ts index 7a4ed54c0d..514ee2e4aa 100644 --- a/src/android/JadxDoctor.ts +++ b/src/android/JadxDoctor.ts @@ -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" diff --git a/src/android/JadxProtocol.ts b/src/android/JadxProtocol.ts index 2e8b42bed9..865b747c99 100644 --- a/src/android/JadxProtocol.ts +++ b/src/android/JadxProtocol.ts @@ -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 | 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), diff --git a/src/android/JadxRelease.ts b/src/android/JadxRelease.ts index 0505954352..e408057af3 100644 --- a/src/android/JadxRelease.ts +++ b/src/android/JadxRelease.ts @@ -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. */ diff --git a/src/android/JadxSession.ts b/src/android/JadxSession.ts index 7c413902ea..ef7bcbccfe 100644 --- a/src/android/JadxSession.ts +++ b/src/android/JadxSession.ts @@ -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, }, ]); } diff --git a/tests/boundary/android/jadxProtocol.test.ts b/tests/boundary/android/jadxProtocol.test.ts index 49a3daf17f..70457bfc8c 100644 --- a/tests/boundary/android/jadxProtocol.test.ts +++ b/tests/boundary/android/jadxProtocol.test.ts @@ -3,9 +3,33 @@ import { parseJadxEnvelope, parseJadxJson, normalizeJadxText, + parseJadxFailure, } from "../../../src/android/JadxProtocol.js"; import { androidInputSchemas } from "../../../src/domain/android/androidAnalysis.js"; +it("recognizes named bridge failures without reinterpreting arbitrary engine text", () => { + const failure = { + kind: "jvm-memory-failure", + memory_area: "heap", + exception_type: "java.lang.OutOfMemoryError", + message: "Java heap space", + max_heap_bytes: 67108864, + }; + expect(parseJadxFailure(JSON.stringify(failure))).toEqual(failure); + for (const value of [ + "load failed: cannot read /inputs/Java heap space.apk", + "{malformed Java heap space", + JSON.stringify({ + ...failure, + exception_type: "java.lang.IllegalArgumentException", + }), + JSON.stringify({ ...failure, max_heap_bytes: -1 }), + JSON.stringify({ ...failure, memory_area: "native" }), + JSON.stringify({ ...failure, arbitrary: true }), + ]) + expect(parseJadxFailure(value)).toBeUndefined(); +}); + it("validates provider text envelopes and rejects unsupported content rather than dropping it", () => { expect( parseJadxEnvelope( diff --git a/tests/boundary/process/jadxIntegration.test.ts b/tests/boundary/process/jadxIntegration.test.ts index f03d34a350..e620fb13c8 100644 --- a/tests/boundary/process/jadxIntegration.test.ts +++ b/tests/boundary/process/jadxIntegration.test.ts @@ -834,7 +834,7 @@ it("reports missing explicit tools without trying to install or launch them", as }); }); -it.each(["tool-error", "frame-overflow", "body-error"])( +it.each(["tool-error", "frame-overflow", "body-error", "heap-decoy"])( "preserves the actual failed provider constraint for %s", async (mode) => { const { service, apk, launches, provider } = await setup(mode); @@ -855,11 +855,17 @@ it.each(["tool-error", "frame-overflow", "body-error"])( _tag: "ProviderAdapterError", diagnostics: { upstream_operation: - mode === "body-error" ? "get_method_body" : "get_android_manifest", + mode === "body-error" + ? "get_method_body" + : mode === "heap-decoy" + ? "load_apk" + : "get_android_manifest", reason: mode === "body-error" ? "// ERROR: decompile timed out or failed" - : "manifest decoder rejected malformed binary XML", + : mode === "heap-decoy" + ? "load failed: cannot read /inputs/Java heap space.apk" + : "manifest decoder rejected malformed binary XML", }, }); else @@ -872,6 +878,88 @@ it.each(["tool-error", "frame-overflow", "body-error"])( }, ); +it.each(["heap-failure", "other-memory-failure"])( + "projects typed JVM memory failure and cleans its process for %s", + async (mode) => { + const { service, apk, launches, provider } = await setup(mode); + const outcome = await service.execute("inspect_android_package", { + path: apk, + }); + if (outcome.ok) throw new Error("Expected memory allocation failure"); + expect(outcome.error).toMatchObject({ + _tag: "AnalysisResourceConstraintError", + resource: "memory", + retryable: false, + reportedLimits: { + observed_max_heap_bytes: 67108864, + memory_area: mode === "heap-failure" ? "heap" : "unknown", + }, + diagnostics: { upstream_operation: "load_apk", target_path: apk }, + }); + const projected = projectAnalysisError(outcome.error); + expect(projected).toMatchObject({ + code: "resource_constraint", + retryable: false, + }); + expect(projected.remediation.action).toContain( + mode === "heap-failure" + ? "REA_JADX_HEAP_MIB" + : "increasing heap alone may not help", + ); + await provider.close(); + await verifyCleanup(launches); + }, +); + +it("retains typed memory failure through failed cleanup and restores it after retry", async () => { + let fails = true; + const { service, apk, launches, provider } = await setup( + "heap-failure", + () => fails, + ); + const rootClose = vi + .spyOn(PrivateRuntimeRoot.prototype, "close") + .mockRejectedValueOnce(new Error("fixture workspace close failure")); + try { + const result = await service.execute("inspect_android_package", { + path: apk, + }); + if (result.ok) throw new Error("Expected failed memory cleanup"); + const failure = result.error; + expect(failure.cleanupIncomplete).toBe(true); + expect(projectAnalysisError(failure)).toMatchObject({ + code: "cleanup_incomplete", + details: { + diagnostics: { primary_error: { code: "resource_constraint" } }, + }, + }); + const workspace = launches[0]?.cwd; + expect(failure.cleanupResources).toContain(workspace); + fails = false; + await expect(provider.close()).rejects.toMatchObject({ + cleanupIncomplete: true, + }); + expect(failure.cleanupResources).toEqual([workspace]); + await provider.close(); + expect(failure.cleanupIncomplete).toBe(false); + expect(failure.cleanupResources).toEqual([]); + expect(projectAnalysisError(failure)).toMatchObject({ + code: "resource_constraint", + retryable: false, + remediation: { action: expect.stringContaining("REA_JADX_HEAP_MIB") }, + details: { + resource: "memory", + reported_limits: { observed_max_heap_bytes: 67108864 }, + }, + }); + await verifyCleanup(launches); + } finally { + rootClose.mockRestore(); + fails = false; + await provider.close(); + } +}); + it("rejects bytes changed after target admission without launching an engine", async () => { const { provider, apk, launches } = await setup(); const target = await parseBinaryTarget(apk); diff --git a/tests/conformance/android/ReaJadxMetadataRegression.java b/tests/conformance/android/ReaJadxMetadataRegression.java index 08487d8d02..734a36d05e 100644 --- a/tests/conformance/android/ReaJadxMetadataRegression.java +++ b/tests/conformance/android/ReaJadxMetadataRegression.java @@ -330,7 +330,93 @@ public class ReaJadxMetadataRegression { System.out.println("NO_BODY_REGRESSION_PASS native=true abstract=true frozen=true"); } + private static void verifyLoadFailures() { + for (String reason : new String[]{"Java heap space", "GC overhead limit exceeded", "Metaspace", "", null}) { + Object projected = ReaJadxBridge.loadResult(kotlin.ResultKt.createFailure(new OutOfMemoryError(reason))); + CallToolResult response = (CallToolResult) projected; + require(Boolean.TRUE.equals(response.isError()), "Memory failure became success"); + JsonObject failure = JsonParser.parseString(((TextContent) response.getContent().get(0)).getText()) + .getAsJsonObject(); + require(failure.get("kind").getAsString().equals("jvm-memory-failure"), "Lost memory tag"); + require(reason == null ? !failure.has("message") : failure.get("message").getAsString().equals(reason), + "Lost JVM reason or invented one"); + require(failure.get("max_heap_bytes").getAsLong() == Runtime.getRuntime().maxMemory(), + "Configured heap substituted for observed heap"); + boolean heap = "Java heap space".equals(reason) || "GC overhead limit exceeded".equals(reason); + require(failure.get("memory_area").getAsString().equals(heap ? "heap" : "unknown"), + "Unknown memory region became heap"); + } + Object cancellation = kotlin.ResultKt.createFailure(new java.util.concurrent.CancellationException("cancel")); + require(ReaJadxBridge.loadResult(cancellation) == cancellation, "Cancellation was stringified"); + Object fatal = kotlin.ResultKt.createFailure(new AssertionError("fatal")); + require(ReaJadxBridge.loadResult(fatal) == fatal, "Fatal error was stringified"); + String decoy = "cannot read /inputs/Java heap space.apk"; + CallToolResult ordinary = (CallToolResult) ReaJadxBridge.loadResult( + kotlin.ResultKt.createFailure(new IllegalArgumentException(decoy))); + require(((TextContent) ordinary.getContent().get(0)).getText().equals("load failed: " + decoy), + "Ordinary exception text became memory evidence"); + var loaded = new SessionHolder.LoadResult("/selected.apk", 2, 3, 4, 5L, 90000L, + 1, 64, "full", List.of("selected"), List.of("excluded")); + var completions = new ArrayList(); + kotlin.coroutines.Continuation receiver = new kotlin.coroutines.Continuation<>() { + public kotlin.coroutines.CoroutineContext getContext() { return EmptyCoroutineContext.INSTANCE; } + public void resumeWith(Object result) { completions.add(result); } + }; + Object immediate = ReaJadxBridge.loadCall(next -> loaded, receiver); + require(immediate instanceof CallToolResult && completions.isEmpty(), "Immediate completion resumed twice"); + JsonObject success = JsonParser.parseString(((TextContent) ((CallToolResult) immediate) + .getContent().get(0)).getText()).getAsJsonObject(); + require(success.get("apk_path").getAsString().equals("/selected.apk") + && success.get("raw_class_count").getAsInt() == 3 + && success.getAsJsonArray("include_packages").get(0).getAsString().equals("selected") + && success.getAsJsonArray("exclude_packages").get(0).getAsString().equals("excluded"), + "Load response lost source facts or selected filters"); + Object thrown = ReaJadxBridge.loadCall(next -> { throw new OutOfMemoryError("Java heap space"); }, receiver); + require(thrown instanceof CallToolResult && completions.isEmpty(), "Synchronous failure resumed twice"); + var pending = new ArrayList>(); + Object suspended = ReaJadxBridge.loadCall(next -> { + require(next.getContext() == receiver.getContext(), "Coroutine context changed"); + pending.add(next); + return kotlin.coroutines.intrinsics.IntrinsicsKt.getCOROUTINE_SUSPENDED(); + }, receiver); + require(suspended == kotlin.coroutines.intrinsics.IntrinsicsKt.getCOROUTINE_SUSPENDED(), + "Suspension became completion"); + require(completions.isEmpty(), "Suspension completed early"); + pending.get(0).resumeWith(cancellation); + require(completions.size() == 1 && completions.get(0) == cancellation, "Late cancellation changed"); + for (Object outcome : List.of(loaded, + kotlin.ResultKt.createFailure(new OutOfMemoryError("Java heap space")))) { + pending.clear(); + completions.clear(); + ReaJadxBridge.loadCall(next -> { + pending.add(next); + return kotlin.coroutines.intrinsics.IntrinsicsKt.getCOROUTINE_SUSPENDED(); + }, receiver); + pending.get(0).resumeWith(outcome); + require(completions.size() == 1 && completions.get(0) instanceof CallToolResult, + "Late success or memory failure was not delivered once"); + } + int[] callbacks = {0}; + var downstreamFailure = new IllegalStateException("downstream rejected completion"); + kotlin.coroutines.Continuation throwing = new kotlin.coroutines.Continuation<>() { + public kotlin.coroutines.CoroutineContext getContext() { return EmptyCoroutineContext.INSTANCE; } + public void resumeWith(Object result) { callbacks[0]++; throw downstreamFailure; } + }; + try { + ReaJadxBridge.loadCall(next -> { + next.resumeWith(loaded); + return kotlin.coroutines.intrinsics.IntrinsicsKt.getCOROUTINE_SUSPENDED(); + }, throwing); + throw new AssertionError("Inline consumer exception became a second completion"); + } catch (IllegalStateException failure) { + require(failure == downstreamFailure && callbacks[0] == 1, "Inline continuation resumed twice"); + } + System.out.println("LOAD_FAILURE_REGRESSION_PASS typed=true decoy=true suspension=true cancellation=true"); + } + public static void main(String[] args) throws Exception { + verifyLoadFailures(); + if (args.length == 1 && args[0].equals("--load-failures")) return; SessionConfig config = new SessionConfig(1048576, 0, 90000L, 1, List.of(), List.of(), 64, ResourceMode.FULL); SessionHolder holder = new SessionHolder(config); try { diff --git a/tests/fixtures/android/jadx-mcp.mjs b/tests/fixtures/android/jadx-mcp.mjs index 6d6e093c2c..a2ac588406 100644 --- a/tests/fixtures/android/jadx-mcp.mjs +++ b/tests/fixtures/android/jadx-mcp.mjs @@ -63,7 +63,10 @@ const register = (name, handler) => register("rea_jvm_status", () => json({ engine_reported_version: mode === "engine-version" ? "0.7.0" : "0.7.1", - max_heap_bytes: 8 * 1024 * 1024 * 1024, + max_heap_bytes: + mode === "heap-failure" || mode === "other-memory-failure" + ? 67108864 + : 8 * 1024 * 1024 * 1024, available_processors: 4, java_version: "21-fixture", metadata_scope: "parsed_members", @@ -71,6 +74,22 @@ register("rea_jvm_status", () => ); register("load_apk", async ({ path }) => { if (mode === "stall") await new Promise(() => {}); + if (mode === "heap-failure" || mode === "other-memory-failure") + return { + ...json({ + kind: "jvm-memory-failure", + memory_area: mode === "heap-failure" ? "heap" : "unknown", + exception_type: "java.lang.OutOfMemoryError", + message: mode === "heap-failure" ? "Java heap space" : "Metaspace", + max_heap_bytes: 67108864, + }), + isError: true, + }; + if (mode === "heap-decoy") + return { + ...text("load failed: cannot read /inputs/Java heap space.apk"), + isError: true, + }; return json({ state: "LOADED", apk_path: mode === "wrong-apk" ? "/different.apk" : path,