diff --git a/bridge/android/ReaJadxBridge.java b/bridge/android/ReaJadxBridge.java index f2686d47c..563ecce16 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 d3907a762..49642d195 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 8693b2be3..8f2a20e5f 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 7a4ed54c0..514ee2e4a 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 2e8b42bed..865b747c9 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 050595435..e408057af 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 7c413902e..ef7bcbccf 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 49a3daf17..70457bfc8 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 f03d34a35..e620fb13c 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 08487d8d0..734a36d05 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 6d6e093c2..a2ac58840 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,