Files
rea/tests/boundary/process/jadxIntegration.test.ts
T

1263 lines
43 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 * as stableHash 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");
// Select the provider's source hash after resolver and engine admission complete.
const failInputHashCloses = (path: string, failures: number) => {
const originalHash = stableHash.hashStableFile;
const originalClose = OwnedFileHandle.prototype.close;
let hashingInput = false;
const retained = new Set<OwnedFileHandle>();
const owners: OwnedFileHandle[] = [];
const close = vi.fn(async (owner: OwnedFileHandle) => {
owners.push(owner);
if (owners.length <= failures)
throw new Error("fixture input hash close unavailable");
return originalClose.call(owner);
});
const hash = vi
.spyOn(stableHash, "hashStableFile")
.mockImplementation(async (selected, options) => {
hashingInput = selected === path;
try {
return await originalHash(selected, options);
} finally {
hashingInput = false;
}
});
const closing = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementation(async function (this: OwnedFileHandle) {
if (!hashingInput && !retained.has(this)) return originalClose.call(this);
retained.add(this);
return close(this);
});
return {
close,
owners,
restore: () => {
closing.mockRestore();
hash.mockRestore();
},
};
};
it("retains a failed input hash descriptor until provider shutdown retries it", async () => {
const { apk, provider, launches } = await setup();
const target = await parseBinaryTarget(apk);
if (!target.ok) throw target.error;
const fault = failInputHashCloses(target.value.path, 2);
const { close, owners } = fault;
try {
const result = await provider.execute(target.value, {
operation: "inspect_android_package",
input: { 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 {
fault.restore();
await provider.close();
await verifyCleanup(launches);
}
});
it("clears hash cleanup uncertainty when the provider immediately retries the same handle", async () => {
const { apk, provider, launches } = await setup();
const target = await parseBinaryTarget(apk);
if (!target.ok) throw target.error;
const fault = failInputHashCloses(target.value.path, 1);
const { close } = fault;
try {
const result = await provider.execute(target.value, {
operation: "inspect_android_package",
input: { 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 {
fault.restore();
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 target = await parseBinaryTarget(apk);
if (!target.ok) throw target.error;
const fault = failInputHashCloses(target.value.path, 1);
const sessionClose = vi
.spyOn(JadxSession.prototype, "close")
.mockRejectedValueOnce(new Error("fixture session cleanup failure"));
try {
const outcome = await provider.execute(target.value, {
operation: "inspect_android_package",
input: { 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(fault.close).toHaveBeenCalledTimes(2);
await expect(access(workspace)).resolves.toBeUndefined();
sessionClose.mockRestore();
fault.restore();
await provider.close();
expect(outcome.error.cleanupIncomplete).toBe(false);
await verifyCleanup(launches);
} finally {
sessionClose.mockRestore();
fault.restore();
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);
});