Files
rea/tests/boundary/process/jadxIntegration.test.ts
T
Paritosh Lahre f433786463 feat(android): make the JADX operation deadline configurable (#4466)
* feat(android): make the JADX operation deadline configurable

JADX operations used a fixed 120 s provider deadline and a separate fixed
120 s per-call bound in the session, so an APK whose cold load exceeds
120 s could not complete, and raising only the MCP client timeout could
not extend either. Add REA_JADX_OPERATION_TIMEOUT_MS, parsed once by the
JADX configuration owner (decimal milliseconds from 1 to 2147483647,
default 120000). The provider resolves it before acquiring anything and
uses it for the operation deadline and its timeout errors, and the session
uses it for every upstream call. Doctor reports the effective value, and
setup forwards an explicit value like the other JADX settings.

Fixes #3918

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(android): isolate JADX fixtures from inherited shell settings

The shared JADX protocol fixture spread the developer's process.env, so an
exported REA_JADX_OPERATION_TIMEOUT_MS (or REA_JADX_HEAP_MIB) changed every
provider test that never selected one. Keep inheriting the host environment
for process launch, but take REA_JADX_* settings only from the fixture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:06:32 +08:00

1105 lines
37 KiB
TypeScript

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("fetches all class pages rather than silently retaining the first upstream page", async () => {
const { service, apk, launches, provider } = await setup();
const outcome = await service.execute("search_android_classes", {
path: apk,
query: "",
});
if (!outcome.ok) throw outcome.error;
const result = androidResultSchemas.search_android_classes.parse(
outcome.value.normalized_result,
);
expect(result.total_classes).toBe(401);
expect(result.matches).toHaveLength(401);
expect(result.matches.at(-1)).toBe("fixture.Class399");
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",
"body-error",
"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"])(
"preserves the actual failed provider constraint 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 a provider failure");
if (mode === "tool-error")
expect(outcome.error).toMatchObject({
_tag: "ProviderAdapterError",
diagnostics: {
reason: "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("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: {
previous_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);
await verifyCleanup(launches);
} finally {
cleanupFails = false;
await provider.close();
}
});
it("does not attribute upstream smali containing all overloads to the selected method", async () => {
const { service, apk, launches, provider } = await setup("overloaded-smali");
const outcome = await service.execute("inspect_android_method", {
path: apk,
class_name: "fixture.Target",
method_name: "choose",
overload_index: 1,
});
expect(outcome).toMatchObject({
ok: false,
error: {
_tag: "AnalysisCapabilityUnavailableError",
reason: expect.stringContaining("joins all same-name overloads"),
},
});
await provider.close();
await verifyCleanup(launches);
});
it("reports missing native/abstract method source without claiming an empty implementation", async () => {
const { service, apk, launches, provider } = await setup("no-body");
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).toContain("no decompiled body");
await provider.close();
await verifyCleanup(launches);
});