import { SdkErrorCode } from "@modelcontextprotocol/client"; import { expect, it as test, onTestFinished, vi } from "vitest"; import { access, readFile, truncate, writeFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js"; import { AndroidAnalysisService } from "../../../src/application/android/AndroidAnalysisService.js"; import { JadxProvider } from "../../../src/android/JadxProvider.js"; import { JadxSession } from "../../../src/android/JadxSession.js"; import { androidResultSchemas } from "../../../src/domain/android/androidAnalysis.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { snapshotAndroidEngine } from "../../../src/android/AndroidTargetSnapshot.js"; import { hashStableFile } from "../../../src/filesystem/StableFileHash.js"; import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js"; import { RegularFileCleanupFailure } from "../../../src/filesystem/OwnedRegularFile.js"; import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js"; import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; import { createJadxProtocolFixture as setup, verifyJadxFixtureCleanup as verifyCleanup, } from "../../fixtures/android/jadx.js"; import { writeJadxJarInventory } from "../../fixtures/android/jadxJar.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; const it = test.skipIf(process.platform === "win32"); it("retains a failed input hash descriptor until provider shutdown retries it", async () => { const { service, apk, provider, launches } = await setup(); const closeCause = new Error("fixture source hash close unavailable"); const owners: OwnedFileHandle[] = []; const originalClose = OwnedFileHandle.prototype.close; const close = vi .spyOn(OwnedFileHandle.prototype, "close") .mockImplementationOnce(async function (this: OwnedFileHandle) { owners.push(this); throw closeCause; }) .mockImplementationOnce(async function (this: OwnedFileHandle) { owners.push(this); throw closeCause; }); try { const result = await service.execute("inspect_android_package", { path: apk, }); expect(result).toMatchObject({ ok: false, error: { cleanupIncomplete: true }, }); expect(close).toHaveBeenCalledTimes(2); expect(owners).toHaveLength(2); expect(owners[0]).toBe(owners[1]); if (result.ok) throw new Error("Expected incomplete hash cleanup"); expect(result.error.cause).toBeInstanceOf(AggregateError); await provider.close(); expect(result.error.cleanupIncomplete).toBe(false); expect(close).toHaveBeenCalledTimes(3); expect(owners[0]!.handle.fd).toBe(-1); expect(launches).toHaveLength(0); } finally { close.mockRestore(); await provider.close(); await verifyCleanup(launches); } }); it("clears hash cleanup uncertainty when the provider immediately retries the same handle", async () => { const { service, apk, provider, launches } = await setup(); const closeCause = new Error("fixture source hash close unavailable"); const originalClose = OwnedFileHandle.prototype.close; const close = vi .spyOn(OwnedFileHandle.prototype, "close") .mockRejectedValueOnce(closeCause) .mockImplementation(async function (this: OwnedFileHandle) { return originalClose.call(this); }); try { const result = await service.execute("inspect_android_package", { path: apk, }); expect(result).toMatchObject({ ok: false }); if (result.ok) throw new Error("Expected the hash close failure"); expect(close).toHaveBeenCalledTimes(2); expect(result.error.cleanupIncomplete).toBe(false); expect(result.error.cause).toBeInstanceOf(AggregateError); expect(launches).toHaveLength(0); } finally { close.mockRestore(); await provider.close(); await verifyCleanup(launches); } }); it("drains hash descriptors even when session shutdown fails and retains its workspace", async () => { const { service, apk, provider, launches } = await setup(); const initial = await service.execute("inspect_android_package", { path: apk, }); if (!initial.ok) throw initial.error; const workspace = launches[0]?.cwd; if (workspace === undefined) throw new Error("Expected an acquired workspace"); const originalClose = OwnedFileHandle.prototype.close; let fileCloseCalls = 0; const fileClose = vi .spyOn(OwnedFileHandle.prototype, "close") .mockImplementation(async function (this: OwnedFileHandle) { fileCloseCalls += 1; if (fileCloseCalls === 1) throw new Error("fixture input hash close failure"); return originalClose.call(this); }); const sessionClose = vi .spyOn(JadxSession.prototype, "close") .mockRejectedValueOnce(new Error("fixture session cleanup failure")); try { const outcome = await service.execute("inspect_android_package", { path: apk, }); expect(outcome).toMatchObject({ ok: false, error: { cleanupIncomplete: true, cleanupResources: [expect.any(String), workspace], }, }); if (outcome.ok) throw new Error("Expected retained session cleanup"); expect(fileCloseCalls).toBe(2); await expect(access(workspace)).resolves.toBeUndefined(); sessionClose.mockRestore(); fileClose.mockRestore(); await provider.close(); expect(outcome.error.cleanupIncomplete).toBe(false); await verifyCleanup(launches); } finally { sessionClose.mockRestore(); fileClose.mockRestore(); await provider.close(); await verifyCleanup(launches); } }); it("cancels Android admission while hashing the selected APK", async () => { const { apk, launches, provider } = await setup(); const target = await parseBinaryTarget(apk); if (!target.ok) throw target.error; await writeFile(apk, Buffer.from([0x50, 0x4b, 0x03, 0x04])); await truncate(apk, 1024 * 1024 * 1024); const controller = new AbortController(); const abortCause = new Error("fixture caller cancelled JADX hashing"); const timer = setTimeout(() => controller.abort(abortCause), 100); try { const result = await provider.execute( target.value, { operation: "inspect_android_package", input: { path: apk } }, { signal: controller.signal }, ); expect(result).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" }, }); if (result.ok) throw new Error("Expected Android admission cancellation"); expect(result.error.cause).toBe(abortCause); expect(launches).toHaveLength(0); } finally { clearTimeout(timer); await provider.close(); await verifyCleanup(launches); } }); it("preserves cancellation cause when hashing succeeded but descriptor close failed", async () => { const { apk, launches, provider } = await setup(); const target = await parseBinaryTarget(apk); if (!target.ok) throw target.error; const controller = new AbortController(); const abortCause = new Error("fixture caller cancelled after hash read"); const closeCause = new Error("fixture hash descriptor close failed"); const owners: OwnedFileHandle[] = []; const originalClose = OwnedFileHandle.prototype.close; const close = vi .spyOn(OwnedFileHandle.prototype, "close") .mockImplementationOnce(async function (this: OwnedFileHandle) { owners.push(this); controller.abort(abortCause); throw closeCause; }) .mockImplementation(async function (this: OwnedFileHandle) { owners.push(this); return originalClose.call(this); }); try { const result = await provider.execute( target.value, { operation: "inspect_android_package", input: { path: apk } }, { signal: controller.signal }, ); expect(result).toMatchObject({ ok: false, error: { cleanupIncomplete: false }, }); if (result.ok) throw new Error("Expected the descriptor close failure"); expect(close).toHaveBeenCalledTimes(2); expect(result.error.cause).toBeInstanceOf(AggregateError); const causes = (result.error.cause as AggregateError).errors; expect(causes[0]).toMatchObject({ _tag: "AnalysisCancelledError" }); expect((causes[0] as Error).cause).toBe(abortCause); const cleanupFailure = (causes[1] as Error).cause; if (!(cleanupFailure instanceof RegularFileCleanupFailure)) throw new Error("Expected the retained hash cleanup failure"); expect(cleanupFailure.cleanupCause).toBe(closeCause); expect(owners[1]).toBe(owners[0]); expect(owners[0]?.handle.fd).toBe(-1); expect(launches).toHaveLength(0); } finally { close.mockRestore(); await provider.close(); await verifyCleanup(launches); } }); it("rejects a copied JAR that differs from its admitted session identity", async () => { const { jar } = await setup(); const hashOwners: OwnedFileHandle[] = []; let admitted: string; try { admitted = ( await hashStableFile(jar, { retainFailedClose: (owner) => hashOwners.push(owner), }) ).sha256; } finally { await Promise.all(hashOwners.map((owner) => owner.close())); } await writeFile(jar, "engine changed between hashing and copying"); const root = await PrivateRuntimeRoot.create({ prefix: "rea-jadx-admission-", }); try { await expect( snapshotAndroidEngine( jar, admitted, root.path, "inspect_android_package", ), ).rejects.toMatchObject({ _tag: "AnalysisCapabilityUnavailableError", reason: expect.stringContaining("bytes changed"), }); } finally { await root.close(); } }); it("reuses immutable engine state while keeping each Evidence observation separate", async () => { const { service, apk, launches, provider } = await setup(); const first = await service.execute("inspect_android_package", { path: apk }); if (!first.ok) throw first.error; const preserved = JSON.stringify(first.value); const second = await service.execute("inspect_android_class", { path: apk, class_name: "fixture.Target", }); if (!second.ok) throw second.error; expect(launches).toHaveLength(1); expect(JSON.stringify(first.value)).toBe(preserved); expect(second.value.raw_result).toMatchObject({ loaded: { class_count: 401 }, calls: [ expect.objectContaining({ operation: "rea_jvm_status" }), expect.objectContaining({ operation: "get_class_summary" }), ], }); expect( androidResultSchemas.inspect_android_class.parse( second.value.normalized_result, ).engine.heap_limit_mib, ).toBe(8192); await provider.close(); await verifyCleanup(launches); }); it("bounds output per operation without repeating prerequisite probes for a retained session", async () => { const { service, apk, launches, provider, javaProbeCount } = await setup("large-manifest"); const expectedManifest = "x".repeat(1024 * 1024); for (let index = 0; index < 34; index += 1) { const result = await service.execute("inspect_android_package", { path: apk, }); if (!result.ok) throw result.error; expect( androidResultSchemas.inspect_android_package.parse( result.value.normalized_result, ).manifest.text, ).toBe(expectedManifest); } expect(launches).toHaveLength(1); expect(await javaProbeCount()).toBe(1); await provider.close(); await verifyCleanup(launches); }); it("retire sessions when APK bytes, engine bytes or JVM options change", async () => { const { service, apk, jar, launches, provider, environment, javaProbeCount } = await setup(); const inspect = async () => { const result = await service.execute("inspect_android_package", { path: apk, }); if (!result.ok) throw result.error; return result.value; }; const first = await inspect(); await writeFile( apk, Buffer.concat([await readFile(apk), Buffer.from("new artifact bytes")]), ); const changed = await inspect(); expect(changed.subject?.digest.sha256).not.toBe(first.subject?.digest.sha256); await verifyCleanup(launches.slice(0, 1)); await writeJadxJarInventory(jar, ["fixture/NewEngineMarker.class"]); await inspect(); environment.JAVA_TOOL_OPTIONS = "-Xmx8g"; environment._JAVA_OPTIONS = "-XX:+UseG1GC"; await inspect(); expect(launches).toHaveLength(4); expect(await javaProbeCount()).toBe(4); expect(launches[3]).toMatchObject({ javaToolOptions: "-Xmx8g", legacyJavaOptions: "-XX:+UseG1GC", }); expect(launches[3]?.arguments).not.toContain("-Xmx512m"); expect(launches[3]?.arguments).not.toContain("-XX:ActiveProcessorCount=1"); await provider.close(); await verifyCleanup(launches); }); it("retires an idle engine and starts a fresh one for a later request", async () => { const { service, apk, launches, provider } = await setup(); vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); try { const first = await service.execute("inspect_android_package", { path: apk, }); if (!first.ok) throw first.error; vi.advanceTimersByTime(60_000); } finally { vi.useRealTimers(); } await expect .poll(async () => { try { await verifyCleanup(launches); return true; } catch { return false; } }) .toBe(true); const later = await service.execute("inspect_android_package", { path: apk }); if (!later.ok) throw later.error; expect(launches).toHaveLength(2); await provider.close(); await verifyCleanup(launches); }); it("completes large class inventories with exact case-sensitive search semantics", async () => { const { service, apk, provider } = await setup("large-inventory"); const full = await service.execute("search_android_classes", { path: apk, query: "", }); if (!full.ok) throw full.error; const inventory = androidResultSchemas.search_android_classes.parse( full.value.normalized_result, ); expect(inventory.matches).toHaveLength(9001); expect(inventory.matches.at(-1)).toBe("fixture.Class8999"); const mixedCase = await service.execute("search_android_classes", { path: apk, query: "target", }); if (!mixedCase.ok) throw mixedCase.error; expect( androidResultSchemas.search_android_classes.parse( mixedCase.value.normalized_result, ).matches, ).toEqual([]); await provider.close(); }); it("close cancels active and queued work and prevents later acquisition", async () => { const { service, apk, launches, provider } = await setup("stall"); const active = service.execute("inspect_android_package", { path: apk }); await expect.poll(() => launches.length).toBe(1); const queued = service.execute("inspect_android_package", { path: apk }); await provider.close(); for (const result of [ await active, await queued, await service.execute("inspect_android_package", { path: apk }), ]) expect(result).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" }, }); expect(launches).toHaveLength(1); await verifyCleanup(launches); }); it("does not carry a factory instance's cleanup failure into another instance", async () => { const failed = await setup("cleanup-failure"); const healthy = await setup(); const failure = await failed.service.execute("inspect_android_package", { path: failed.apk, }); expect(failure).toMatchObject({ ok: false, error: { cleanupIncomplete: true }, }); const result = await healthy.service.execute("inspect_android_package", { path: healthy.apk, }); expect(result.ok).toBe(true); await healthy.provider.close(); await verifyCleanup(healthy.launches); }); it("retains APK identity, original input, raw producer data and normalized observations", async () => { const { service, apk, launches, provider } = await setup(); const outcome = await service.execute("inspect_android_package", { path: apk, }); if (!outcome.ok) throw outcome.error; const result = androidResultSchemas.inspect_android_package.parse( outcome.value.normalized_result, ); expect(result.package_name).toBe("fixture"); expect(result.engine.source_revision).toBeNull(); expect(outcome.value.subject?.local_path).toBe(apk); expect(outcome.value.parameters).toEqual({ path: apk }); expect(outcome.value.raw_result).toMatchObject({ calls: expect.arrayContaining([ expect.objectContaining({ operation: "get_android_manifest" }), ]), }); await provider.close(); await verifyCleanup(launches); }); it("pages matching classes independently of the total APK class count", async () => { const { service, apk, launches, provider } = await setup("large-inventory"); const outcome = await service.execute("search_android_classes", { path: apk, query: "Class", }); if (!outcome.ok) throw outcome.error; const result = androidResultSchemas.search_android_classes.parse( outcome.value.normalized_result, ); expect(result.total_classes).toBe(9001); expect(result.matches).toHaveLength(9000); expect(result.matches[0]).toBe("fixture.Class0"); expect(result.matches[4096]).toBe("fixture.Class4096"); expect(result.matches.at(-1)).toBe("fixture.Class8999"); await provider.close(); await verifyCleanup(launches); }); it("preserves duplicate class-name rows and occurrence counts", async () => { const { service, apk, launches, provider } = await setup( "duplicate-class-name", ); const outcome = await service.execute("search_android_classes", { path: apk, query: "fixture.Target", }); if (!outcome.ok) throw outcome.error; const result = androidResultSchemas.search_android_classes.parse( outcome.value.normalized_result, ); expect(result.total_classes).toBe(402); expect(result.matches).toEqual(["fixture.Target", "fixture.Target"]); expect(result.duplicate_names).toEqual([ { name: "fixture.Target", occurrences: 2 }, ]); const full = await service.execute("search_android_classes", { path: apk, query: "", }); if (!full.ok) throw full.error; const fullResult = androidResultSchemas.search_android_classes.parse( full.value.normalized_result, ); expect(fullResult.total_classes).toBe(402); expect(fullResult.matches).toHaveLength(402); expect(fullResult.duplicate_names).toEqual([ { name: "fixture.Target", occurrences: 2 }, ]); await provider.close(); await verifyCleanup(launches); }); it("exposes overload candidates and executes only an explicitly selected ambiguous method", async () => { const { service, apk, launches, provider } = await setup(); const input = { path: apk, class_name: "fixture.Target", method_name: "choose", }; const ambiguous = await service.execute("inspect_android_method", input); expect(ambiguous).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError", issues: [ { path: ["overload_index"], message: expect.stringContaining("1: choose(int)"), }, ], }, }); const selected = await service.execute("inspect_android_method", { ...input, overload_index: 1, }); if (!selected.ok) throw selected.error; const result = androidResultSchemas.inspect_android_method.parse( selected.value.normalized_result, ); expect(result.method).toMatchObject({ overload_index: 1, reported_signature: "choose(int): void", dex_descriptor: "(I)V", }); expect(result.source.text).toContain("overload 1"); const references = await service.execute("trace_android_references", input); expect(references).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError", issues: [expect.objectContaining({ path: ["overload_index"] })], }, }); const selectedReferences = await service.execute("trace_android_references", { ...input, overload_index: 1, }); if (!selectedReferences.ok) throw selectedReferences.error; expect( androidResultSchemas.trace_android_references.parse( selectedReferences.value.normalized_result, ), ).toMatchObject({ overload_index: 1, reported_signature: "choose(int): void", }); await provider.close(); await verifyCleanup(launches); }); it.each([ "wrong-apk", "empty-page", "summary-error", "wrong-method", "wrong-descriptor", "partial-xrefs", ])( "rejects producer failure %s rather than claiming complete analysis", async (mode) => { const { service, apk, launches, provider } = await setup(mode); const operation = mode === "wrong-apk" ? "inspect_android_package" : mode === "empty-page" ? "search_android_classes" : mode === "partial-xrefs" ? "trace_android_references" : "inspect_android_method"; const outcome = await service.execute(operation, { path: apk, ...(operation === "search_android_classes" ? { query: "" } : operation === "inspect_android_package" ? {} : { class_name: "fixture.Target", method_name: "onCreate" }), }); expect(outcome).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" }, }); await provider.close(); await verifyCleanup(launches); }, ); it("rejects fuzzy class resolution and unsupported upstream versions", async () => { for (const mode of ["wrong-class", "version", "engine-version"]) { const { service, apk, launches, provider } = await setup(mode); const outcome = await service.execute("inspect_android_class", { path: apk, class_name: "fixture.Target", }); expect(outcome).toMatchObject({ ok: false, error: { _tag: mode === "version" || mode === "engine-version" ? "AnalysisCapabilityUnavailableError" : "AnalysisInputError", }, }); await provider.close(); await verifyCleanup(launches); } }); it.each(["truncated", "smali"])( "retains %s source coverage instead of presenting it as successful complete Java", async (mode) => { const { service, apk, launches, provider } = await setup(mode); const outcome = await service.execute("inspect_android_method", { path: apk, class_name: "fixture.Target", method_name: "onCreate", }); if (!outcome.ok) throw outcome.error; const result = androidResultSchemas.inspect_android_method.parse( outcome.value.normalized_result, ); expect(result.source.status).toBe( mode === "truncated" ? "partial" : "complete", ); expect(result.representation).toBe(mode === "smali" ? "smali" : "java"); if (mode === "truncated") expect(result.source.reported_total_bytes).toBe(100); else expect(result.fell_back).toBe(true); await provider.close(); await verifyCleanup(launches); }, ); it("cancels active and queued requests, cleans ownership and never launches a queued cancelled request", async () => { const { service, apk, launches, provider } = await setup("stall"); const activeController = new AbortController(); const active = service.execute( "inspect_android_package", { path: apk }, { signal: activeController.signal }, ); await expect.poll(() => launches.length).toBe(1); const queuedController = new AbortController(); const queued = service.execute( "inspect_android_package", { path: apk }, { signal: queuedController.signal }, ); queuedController.abort(); expect(await queued).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" }, }); expect(launches).toHaveLength(1); activeController.abort(); expect(await active).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" }, }); await provider.close(); await verifyCleanup(launches); }); it("ends each queued stalled operation at the caller-selected deadline and cleans ownership", async () => { const { service, apk, launches, provider } = await setup( "stall", undefined, undefined, { REA_JADX_OPERATION_TIMEOUT_MS: "1500" }, ); const started = performance.now(); // The second request waits behind the first; its deadline starts at the // queue front, so it gets its own engine launch and runtime reply. const outcomes = await Promise.all([ service.execute("inspect_android_package", { path: apk }), service.execute("inspect_android_package", { path: apk }), ]); expect(performance.now() - started).toBeLessThan(20_000); for (const outcome of outcomes) { expect(outcome).toMatchObject({ ok: false, error: { _tag: "AnalysisTimeoutError", timeoutMs: 1500, partialObservation: { calls: [expect.objectContaining({ operation: "rea_jvm_status" })], }, }, }); if (outcome.ok) throw new Error("Expected the selected deadline"); expect(projectAnalysisError(outcome.error)).toMatchObject({ code: "provider_timeout", details: { timeout_ms: 1500 }, }); } expect(launches).toHaveLength(2); // The deadline path itself releases ownership before provider shutdown. await verifyCleanup(launches); await provider.close(); }); it("completes an operation under the largest accepted deadline", async () => { const { service, apk, provider, launches } = await setup( "normal", undefined, undefined, { REA_JADX_OPERATION_TIMEOUT_MS: "2147483647" }, ); const outcome = await service.execute("inspect_android_package", { path: apk, }); expect(outcome.ok).toBe(true); await provider.close(); await verifyCleanup(launches); }); it.each(["", "2147483648"])( "rejects REA_JADX_OPERATION_TIMEOUT_MS=%j before probing or launching Java", async (value) => { const { service, apk, launches, javaProbeCount } = await setup( "normal", undefined, undefined, { REA_JADX_OPERATION_TIMEOUT_MS: value }, ); const outcome = await service.execute("inspect_android_package", { path: apk, }); expect(outcome).toMatchObject({ ok: false, error: { _tag: "AnalysisCapabilityUnavailableError", reason: expect.stringContaining("REA_JADX_OPERATION_TIMEOUT_MS"), }, }); expect(launches).toHaveLength(0); // The fake JDK records each module-listing probe; none may have run. await expect(javaProbeCount()).rejects.toMatchObject({ code: "ENOENT" }); }, ); it("bounds each upstream call by the selected deadline instead of a fixed limit", async () => { // Provider workflows cannot reach the former fixed 120-second call limit in // a test, so exercise the session boundary that owns each upstream call. const { apk } = await setup(); const target = await parseBinaryTarget(apk); if (!target.ok) throw target.error; const workspace = await createTestTempDirectory("rea-jadx-call-deadline-"); const pids: number[] = []; const session = new JadxSession( { command: process.execPath, arguments: [], cwd: workspace, hostEnvironment: process.env, signal: new AbortController().signal, }, async (options) => { const spawned = await spawnOwnedProviderProcess({ ...options, command: process.execPath, arguments: [ fileURLToPath( new URL("../../fixtures/android/jadx-mcp.mjs", import.meta.url), ), ], expectedCommand: process.execPath, env: { ...options.env, REA_FAKE_JADX_MODE: "stall" }, }); if (spawned.process.pid !== undefined) pids.push(spawned.process.pid); return spawned; }, ); onTestFinished(() => session.close()); try { const started = performance.now(); await expect( session.execute({ request: { operation: "inspect_android_package", input: { path: apk } }, target: target.value, snapshot: apk, jarHash: "0".repeat(64), bridgeHash: "0".repeat(64), signal: new AbortController().signal, timeoutMs: 300, }), ).rejects.toMatchObject({ code: SdkErrorCode.RequestTimeout, data: { timeout: 300 }, }); expect(performance.now() - started).toBeLessThan(10_000); expect(session.partialObservation()?.calls).toEqual([ expect.objectContaining({ operation: "rea_jvm_status" }), ]); } finally { await session.close(); } expect(pids).toHaveLength(1); for (const pid of pids) expect(() => process.kill(-pid, 0)).toThrowError( expect.objectContaining({ code: "ESRCH" }), ); }); it("reports missing explicit tools without trying to install or launch them", async () => { const { apk } = await setup(); const provider = new JadxProvider({}, () => { throw new Error("must not launch"); }); const outcome = await new AndroidAnalysisService(provider).execute( "inspect_android_package", { path: apk }, ); expect(outcome).toMatchObject({ ok: false, error: { _tag: "AnalysisCapabilityUnavailableError", reason: expect.stringContaining("REA_JADX_MCP_JAR"), }, }); }); 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); const outcome = await service.execute( mode === "body-error" ? "inspect_android_method" : "inspect_android_package", { path: apk, ...(mode === "body-error" ? { class_name: "fixture.Target", method_name: "onCreate" } : {}), }, ); if (outcome.ok) throw new Error("Expected a provider failure"); if (mode !== "frame-overflow") expect(outcome.error).toMatchObject({ _tag: "ProviderAdapterError", diagnostics: { upstream_operation: mode === "body-error" ? "get_method_body" : mode === "heap-decoy" ? "load_apk" : "get_android_manifest", reason: mode === "body-error" ? "// ERROR: decompile timed out or failed" : mode === "heap-decoy" ? "load failed: cannot read /inputs/Java heap space.apk" : "manifest decoder rejected malformed binary XML", }, }); else expect(outcome.error).toMatchObject({ _tag: "AnalysisOutputError", reason: expect.stringContaining("MCP frame exceeds"), }); await provider.close(); await verifyCleanup(launches); }, ); 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 originalRootClose = PrivateRuntimeRoot.prototype.close; const workspaceCloseCause = new Error("fixture workspace close failure"); let workspaceCloseFails = true; let workspaceCloseAttempts = 0; const rootClose = vi .spyOn(PrivateRuntimeRoot.prototype, "close") .mockImplementation(function (this: PrivateRuntimeRoot) { // Independent native helpers own their own roots. Keep the acquired // Android workspace's failure active until the test explicitly clears it. if (this.path === launches[0]?.cwd) { workspaceCloseAttempts += 1; if (workspaceCloseFails) return Promise.reject(workspaceCloseCause); } return originalRootClose.call(this); }); 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); expect(workspaceCloseAttempts).toBe(0); fails = false; await expect(provider.close()).rejects.toMatchObject({ cleanupIncomplete: true, }); expect(failure.cleanupResources).toEqual([workspace]); expect(workspaceCloseAttempts).toBeGreaterThan(0); workspaceCloseFails = false; 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(); workspaceCloseFails = false; 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); if (!target.ok) throw target.error; await writeFile( apk, Buffer.concat([ await readFile(apk), Buffer.from("changed after admission"), ]), ); const outcome = await provider.execute(target.value, { operation: "inspect_android_package", input: { path: apk }, }); expect(outcome).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError", issues: [{ message: expect.stringContaining("bytes changed") }], }, }); expect(launches).toHaveLength(0); }); it("retains an uncertain workspace and blocks later launches after cleanup cannot be verified", async () => { const { service, apk, launches } = await setup("cleanup-failure"); const outcome = await service.execute("inspect_android_package", { path: apk, }); expect(outcome).toMatchObject({ ok: false, error: { cleanupIncomplete: true }, }); const workspace = launches[0]?.cwd; if (workspace === undefined) throw new Error("Expected acquired workspace"); await expect(access(workspace)).resolves.toBeUndefined(); expect( await service.execute("inspect_android_package", { path: apk }), ).toMatchObject({ ok: false, error: { cleanupIncomplete: true } }); expect(launches).toHaveLength(1); }); it("resolves every reported shutdown cleanup failure after ownership recovers", async () => { let cleanupFails = true; const { service, apk, launches, provider } = await setup( "normal", () => cleanupFails, ); const closeAfterRecovery = async () => { cleanupFails = false; // Closing the transport can leave the child between live and exited // snapshots. Retry retained ownership until cleanup is actually verified. await expect .poll(() => provider.close(), { timeout: 5_000 }) .toBeUndefined(); }; try { const outcome = await service.execute("inspect_android_package", { path: apk, }); if (!outcome.ok) throw outcome.error; const firstClose = provider.close(); expect(provider.close()).toBe(firstClose); const firstFailure = await firstClose.then( () => { throw new Error("Expected shutdown cleanup to remain incomplete"); }, (cause: unknown) => cause, ); expect(firstFailure).toMatchObject({ cleanupIncomplete: true }); expect(firstFailure).toHaveProperty("cause", expect.any(AggregateError)); const secondFailure = await provider.close().then( () => { throw new Error( "Expected the second cleanup retry to remain incomplete", ); }, (cause: unknown) => cause, ); expect(secondFailure).toMatchObject({ cleanupIncomplete: true }); expect(secondFailure).toHaveProperty("cause", expect.any(AggregateError)); const workspace = launches[0]?.cwd; if (workspace === undefined) throw new Error("Expected acquired workspace"); await expect(access(workspace)).resolves.toBeUndefined(); await closeAfterRecovery(); expect(firstFailure).toMatchObject({ cleanupIncomplete: false }); expect(secondFailure).toMatchObject({ cleanupIncomplete: false }); await verifyCleanup(launches); expect(launches).toHaveLength(1); } finally { await closeAfterRecovery(); } }); it.each(["idle", "target-change"])( "retries %s retirement before acquiring another engine", async (retirement) => { let cleanupFails = true; let cleanupAttempts = 0; const { service, apk, jar, launches, provider } = await setup( "normal", () => { cleanupAttempts += 1; return cleanupFails; }, ); try { if (retirement === "idle") vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); const first = await service.execute("inspect_android_package", { path: apk, }); if (!first.ok) throw first.error; if (retirement === "idle") { vi.advanceTimersByTime(60_000); vi.useRealTimers(); await expect.poll(() => cleanupAttempts).toBeGreaterThan(0); } else { await writeJadxJarInventory(jar, ["fixture/NewEngineMarker.class"]); expect( await service.execute("inspect_android_package", { path: apk }), ).toMatchObject({ ok: false, error: { cleanupIncomplete: true }, }); } const workspace = launches[0]?.cwd; if (workspace === undefined) throw new Error("Expected acquired workspace"); await expect(access(workspace)).resolves.toBeUndefined(); expect(launches).toHaveLength(1); cleanupFails = false; const retried = await service.execute("inspect_android_package", { path: apk, }); if (!retried.ok) throw retried.error; expect(launches).toHaveLength(2); await provider.close(); await verifyCleanup(launches); } finally { vi.useRealTimers(); cleanupFails = false; await provider.close(); } }, ); it("retains the execution failure while cleanup is pending and resumes after cleanup recovers", async () => { let cleanupFails = true; const { service, apk, launches, provider } = await setup( "tool-error", () => cleanupFails, ); try { const outcome = await service.execute("inspect_android_package", { path: apk, }); expect(outcome).toMatchObject({ ok: false, error: { cleanupIncomplete: true, diagnostics: { primary_error: { details: { diagnostics: { reason: "manifest decoder rejected malformed binary XML", }, }, }, }, }, }); if (outcome.ok) throw new Error("Expected cleanup failure"); expect(outcome.error.cause).toBeInstanceOf(AggregateError); const causes = (outcome.error.cause as AggregateError).errors; expect(causes[0]).toMatchObject({ _tag: "ProviderAdapterError", diagnostics: { reason: "manifest decoder rejected malformed binary XML", }, }); expect( await service.execute("inspect_android_package", { path: apk }), ).toMatchObject({ ok: false, error: { cleanupIncomplete: true }, }); expect(launches).toHaveLength(1); cleanupFails = false; const retried = await service.execute("inspect_android_package", { path: apk, }); expect(retried).toMatchObject({ ok: false, error: { cleanupIncomplete: false, diagnostics: { reason: "manifest decoder rejected malformed binary XML", }, }, }); expect(launches).toHaveLength(2); expect(outcome.error.cleanupIncomplete).toBe(false); expect(outcome.error.cleanupResources).toEqual([]); expect(projectAnalysisError(outcome.error)).toMatchObject({ code: "execution_failure", details: { diagnostics: { reason: "manifest decoder rejected malformed binary XML", }, }, }); await verifyCleanup(launches); } finally { cleanupFails = false; await provider.close(); } }); it("accepts exact selected-overload smali from the bridge", async () => { const { service, apk, launches, provider } = await setup( "selected-overload-smali", ); const outcome = await service.execute("inspect_android_method", { path: apk, class_name: "fixture.Target", method_name: "choose", overload_index: 1, }); if (!outcome.ok) throw outcome.error; const result = androidResultSchemas.inspect_android_method.parse( outcome.value.normalized_result, ); expect(result).toMatchObject({ method: { overload_index: 1, dex_descriptor: "(I)V" }, representation: "smali", body_status: "available", }); await provider.close(); await verifyCleanup(launches); }); it("retains parsed native body absence without claiming an empty implementation", async () => { const { service, apk, launches, provider } = await setup("native-method"); const outcome = await service.execute("inspect_android_method", { path: apk, class_name: "fixture.Target", method_name: "onCreate", }); if (!outcome.ok) throw outcome.error; const result = androidResultSchemas.inspect_android_method.parse( outcome.value.normalized_result, ); expect(result.body_status).toBe("not_available"); expect(result.source.text).toBe(""); expect(result.markers).toEqual(["native_method"]); await provider.close(); await verifyCleanup(launches); });