diff --git a/.agents/skills/reverse-engineer-anything/references/android-applications.md b/.agents/skills/reverse-engineer-anything/references/android-applications.md index 896f15f85..912a42f5b 100644 --- a/.agents/skills/reverse-engineer-anything/references/android-applications.md +++ b/.agents/skills/reverse-engineer-anything/references/android-applications.md @@ -24,7 +24,9 @@ matching bundled native controls and uses an owned Job Object with bidirectional stdio. A full JDK, including `jdk.compiler`, is required on every host; Windows `JAVA_HOME` selects `bin\java.exe`. Consult the Android guide for actual host verification coverage. Calls use one worker and provider deadlines; optional -`REA_JADX_HEAP_MIB` selects the JVM heap. The APK is never launched, +`REA_JADX_HEAP_MIB` selects the JVM heap and `REA_JADX_OPERATION_TIMEOUT_MS` +the per-operation deadline (default 120000 ms; the MCP client's tool timeout +must also allow it). The APK is never launched, and no emulator is required. Manifest entries and decompiled text are static observations, not execution or diff --git a/docs/android-analysis.md b/docs/android-analysis.md index 1bad04353..7d81a2b90 100644 --- a/docs/android-analysis.md +++ b/docs/android-analysis.md @@ -300,8 +300,25 @@ down to MiB and retains exact bytes, Java version and visible processors in its raw runtime response. The observed allocatable heap can differ from configured `-Xmx`, especially with Serial GC. Heap is not a total RSS cap: the JVM also uses native memory. Independent REA instances have independent queues. Whole -operations have a **120-second** execution deadline after reaching the queue's -front; upstream decompilation is limited to **90 seconds**. +operations have a **120-second** execution deadline by default, starting after +they reach the queue's front. For a known slow cold load, such as a large +multidex APK, select another finite deadline: + +```sh +export REA_JADX_OPERATION_TIMEOUT_MS=600000 +``` + +The value must be a decimal millisecond count from 1 to 2147483647; anything +else is refused before Java starts. The same deadline bounds each upstream +call, a timeout reports the selected value, and a passing JADX configuration +check in `rea doctor` reports the effective `operation_timeout_ms`. Explicit +cancellation still ends the operation immediately, and partial observations +and owned-process cleanup are unchanged. Raising the deadline does not extend +the bridge's 30-second startup handshake or the upstream **90-second** +decompilation limit. MCP clients enforce their own tool-call timeouts, and +setup's derived [client tool timeout](client-configuration.md) does not include +this setting, so also raise the client timeout when selecting a longer +deadline. Decoded manifest and method text have a **1 MiB** upstream byte budget and report truncation. Class-name pages use indexed slices of up to 4,096 names without changing the diff --git a/docs/client-configuration.md b/docs/client-configuration.md index add6ac002..9cdc4e62d 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -151,7 +151,10 @@ An explicit `REA_JEB_MCP_TIMEOUT_MS` request budget also contributes to that maximum. For `ida` or `auto` selection, a valid IDA registration's parsed `timeoutMs` also contributes to that maximum; its default is 300000 ms. Setup forwards these explicitly selected REA deadlines and the input schema -profile into the registration; unrelated process values are omitted. +profile into the registration; unrelated process values are omitted. An +explicit `REA_JADX_OPERATION_TIMEOUT_MS` is forwarded with the JADX +registration but does not contribute to the derived tool timeout; raise +`toolTimeoutMs` to cover a longer selected JADX deadline. This headroom is a practical default, not a guarantee for every analysis duration. Setup preserves positive timeout values already in the `rea` entry. You can edit `startupTimeoutMs` and `toolTimeoutMs` there; doctor keeps positive @@ -245,6 +248,9 @@ values in the REA entry, and doctor reports a non-blocking advisory when a tool timeout is shorter than the known provider allowance. An explicitly selected JEB request budget also contributes to the allowance. A configured IDA registration's parsed `timeoutMs` contributes for `ida` or `auto` selection. +An explicit `REA_JADX_OPERATION_TIMEOUT_MS` is forwarded with the JADX +registration but does not contribute; raise `tool_timeout_sec` to cover a +longer selected JADX deadline. These values are client-side deadlines, so increase them in `~/.codex/config.toml` if a workload needs more time. Devin honors `XDG_CONFIG_HOME` for its `devin/mcp_config.json` on non-Windows systems. diff --git a/src/android/JadxConfiguration.ts b/src/android/JadxConfiguration.ts index e07e2c211..fe517cb70 100644 --- a/src/android/JadxConfiguration.ts +++ b/src/android/JadxConfiguration.ts @@ -3,9 +3,11 @@ import { access, realpath, stat } from "node:fs/promises"; import { delimiter, isAbsolute, join, resolve } from "node:path"; import { inspectWindowsNativeReadiness } from "../process/WindowsAuthority.js"; import { systemWindowsNativeAuthority } from "../windows/WindowsNativeLoader.js"; +import { providerStartupTimeoutSchema } from "../config/providerStartupTimeout.js"; import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js"; import type { AndroidOperation } from "../domain/android/androidAnalysis.js"; import type { JsonValue } from "../domain/jsonValue.js"; +import { err, ok, type Result } from "../domain/result.js"; import type { ProviderAvailability } from "../application/AnalysisProvider.js"; import { ZipArtifactReader } from "../artifacts/ZipArtifactReader.js"; import { @@ -14,10 +16,12 @@ import { } from "../process/ExecFileOutput.js"; import type { DoctorProviderCheck } from "../application/Doctor.js"; import { + DEFAULT_JADX_OPERATION_TIMEOUT_MS, JADX_JAR_CONFIGURATION_REMEDIATION, JADX_JAVA_PROBE_REMEDIATION, JADX_JAVA_RUNTIME_REMEDIATION, JADX_JVM_OPTIONS_REMEDIATION, + JADX_OPERATION_TIMEOUT_REMEDIATION, JADX_UNSUPPORTED_HOST_REMEDIATION, JADX_WINDOWS_NATIVE_REMEDIATION, } from "./JadxRelease.js"; @@ -57,6 +61,7 @@ export type JadxFailureKind = | "java_runtime" | "java_home" | "jvm_options" + | "operation_timeout" | "jar" | "java_probe" | "unsupported_host"; @@ -68,6 +73,7 @@ export const JADX_FAILURE_KINDS: readonly JadxFailureKind[] = [ "java_runtime", "java_home", "jvm_options", + "operation_timeout", "jar", "java_probe", "unsupported_host", @@ -114,6 +120,11 @@ export const projectJadxFailureOutcome = ( remediation: JADX_JVM_OPTIONS_REMEDIATION, classification: drift, }; + case "operation_timeout": + return { + remediation: JADX_OPERATION_TIMEOUT_REMEDIATION, + classification: drift, + }; case "jar": return { remediation: JADX_JAR_CONFIGURATION_REMEDIATION, @@ -155,6 +166,7 @@ export const inspectJadxAvailability = async ( signal?: AbortSignal, ): Promise => { try { + const operationTimeoutMs = readOperationTimeout(environment); const configuration = await readJadxConfigurationInputs( environment, signal, @@ -169,6 +181,7 @@ export const inspectJadxAvailability = async ( java_executable: configuration.java, jdk_compiler: true, jar_path: configuration.jar, + operation_timeout_ms: operationTimeoutMs, }, }; } catch (cause) { @@ -184,6 +197,22 @@ export const inspectJadxAvailability = async ( } }; +/** + * Resolve the whole-operation deadline before any admission work starts, so + * one value bounds the provider operation and each upstream call. + */ +export const resolveJadxOperationTimeout = ( + environment: Readonly>, + operation: AndroidOperation, +): Result => { + try { + return ok(readOperationTimeout(environment)); + } catch (cause) { + if (!(cause instanceof JadxConfigurationFailure)) throw cause; + return err(projectConfigurationFailure(operation, cause)); + } +}; + /** Resolve caller-selected paths and JVM options without probing their contents. */ export const resolveJadxConfigurationInputs = async ( environment: Readonly>, @@ -508,6 +537,21 @@ const resolveJvmArguments = ( return result; }; +const readOperationTimeout = ( + environment: Readonly>, +): number => { + const value = environment.REA_JADX_OPERATION_TIMEOUT_MS; + if (value === undefined) return DEFAULT_JADX_OPERATION_TIMEOUT_MS; + const parsed = providerStartupTimeoutSchema.safeParse(value); + if (parsed.success) return parsed.data; + throw configurationFailure( + "open_options_invalid", + `REA_JADX_OPERATION_TIMEOUT_MS must be a decimal millisecond count from 1 to 2147483647; received ${value}.`, + { setting: "REA_JADX_OPERATION_TIMEOUT_MS", value }, + "operation_timeout", + ); +}; + const inspectJavaRuntime = async ( java: string, environment: Readonly>, diff --git a/src/android/JadxDoctor.ts b/src/android/JadxDoctor.ts index b357d1e85..7a4ed54c0 100644 --- a/src/android/JadxDoctor.ts +++ b/src/android/JadxDoctor.ts @@ -49,6 +49,10 @@ export const inspectJadxProvider = async ( "REA_JADX_ACTIVE_PROCESSOR_COUNT", environment.REA_JADX_ACTIVE_PROCESSOR_COUNT, ), + ...optionalEntry( + "REA_JADX_OPERATION_TIMEOUT_MS", + environment.REA_JADX_OPERATION_TIMEOUT_MS, + ), }, checks: [configurationCheck(configured, availability, jarPath)], }; @@ -70,6 +74,7 @@ const configurationCheck = ( ): DoctorProviderCheck => { const code = availability.status === "unavailable" ? availability.code : null; if (availability.status === "available" && jarPath !== undefined) { + const operationTimeout = availability.diagnostics["operation_timeout_ms"]; return { name: "configuration", ok: true, @@ -77,6 +82,9 @@ const configurationCheck = ( detail: `JADX MCP bridge JAR validated at ${jarPath}; its path is recorded for agent registrations.`, remediation: null, classification: "config_drift", + ...(typeof operationTimeout === "number" + ? { details: { operation_timeout_ms: operationTimeout } } + : {}), }; } // The failure kind is tagged at the configuration boundary; project its diff --git a/src/android/JadxProvider.ts b/src/android/JadxProvider.ts index 39b13871b..8fb456c64 100644 --- a/src/android/JadxProvider.ts +++ b/src/android/JadxProvider.ts @@ -34,13 +34,13 @@ import { RegularFileCleanupFailure } from "../filesystem/OwnedRegularFile.js"; import { inspectJadxAvailability, resolveJadxConfigurationInputs, + resolveJadxOperationTimeout, validateJadxConfiguration, } from "./JadxConfiguration.js"; import type { JadxLauncher } from "./JadxMcpTransport.js"; import { JadxSession } from "./JadxSession.js"; -const OPERATION_TIMEOUT_MS = 120_000; const BRIDGE_SOURCE = fileURLToPath( new URL("../../bridge/android/ReaJadxBridge.java", import.meta.url), ); @@ -65,10 +65,11 @@ const executionError = (context: { cause: unknown; request: AndroidRequest; timeout: AbortSignal; + timeoutMs: number; signal: AbortSignal | undefined; session: JadxSession | undefined; }): AnalysisError => { - const { cause, request, timeout, signal, session } = context; + const { cause, request, timeout, timeoutMs, signal, session } = context; if (cause instanceof RegularFileCleanupFailure) { const primaryCause = cause.outcome.kind === "failed" @@ -86,7 +87,7 @@ const executionError = (context: { : timeout.aborted ? new AnalysisTimeoutError( request.operation, - OPERATION_TIMEOUT_MS, + timeoutMs, primaryOptions, ) : primaryCause instanceof AnalysisError @@ -114,7 +115,7 @@ const executionError = (context: { if (signal?.aborted === true) return new AnalysisCancelledError(request.operation, { cause }); if (timeout.aborted) - return new AnalysisTimeoutError(request.operation, OPERATION_TIMEOUT_MS); + return new AnalysisTimeoutError(request.operation, timeoutMs); if (cause instanceof AnalysisError) return cause; const protocolFailure = session?.transport.failureReason(); if (protocolFailure !== undefined && protocolFailure !== null) @@ -440,7 +441,14 @@ export class JadxProvider implements AndroidAnalysisPort { request: AndroidRequest, options?: ExecutionOptions, ): Promise { - const timeout = AbortSignal.timeout(OPERATION_TIMEOUT_MS); + // The deadline is fixed per provider environment; an unusable value is + // refused before any file handle, workspace, or engine is acquired. + const timeoutMs = resolveJadxOperationTimeout( + this.environment, + request.operation, + ); + if (!timeoutMs.ok) return timeoutMs; + const timeout = AbortSignal.timeout(timeoutMs.value); const signal = options?.signal === undefined ? timeout @@ -596,6 +604,7 @@ export class JadxProvider implements AndroidAnalysisPort { jarHash: retained.jarHash, bridgeHash: retained.bridgeHash, signal: activeSignal, + timeoutMs: timeoutMs.value, }), ); activeSignal.throwIfAborted(); @@ -606,6 +615,7 @@ export class JadxProvider implements AndroidAnalysisPort { cause, request, timeout, + timeoutMs: timeoutMs.value, signal: options?.signal, session, }); diff --git a/src/android/JadxRelease.ts b/src/android/JadxRelease.ts index 447886b52..5db3b3c99 100644 --- a/src/android/JadxRelease.ts +++ b/src/android/JadxRelease.ts @@ -23,6 +23,12 @@ export const JADX_JAVA_RUNTIME_REMEDIATION = export const JADX_JVM_OPTIONS_REMEDIATION = "Set REA_JADX_HEAP_MIB to a positive integer (heap size in MiB) and REA_JADX_ACTIVE_PROCESSOR_COUNT to a positive integer within the JVM's signed 32-bit range."; +/** Whole-operation deadline used when REA_JADX_OPERATION_TIMEOUT_MS is unset. */ +export const DEFAULT_JADX_OPERATION_TIMEOUT_MS = 120_000; + +/** Remediation when the JADX operation deadline override is malformed. */ +export const JADX_OPERATION_TIMEOUT_REMEDIATION = `Set REA_JADX_OPERATION_TIMEOUT_MS to a decimal millisecond count from 1 to 2147483647, or unset it to keep the ${String(DEFAULT_JADX_OPERATION_TIMEOUT_MS)} ms default.`; + /** Remediation when the host cannot run the Android JADX subprocess. */ export const JADX_UNSUPPORTED_HOST_REMEDIATION = "Use Linux, macOS, or Windows x64 with its bundled native controls for Android JADX analysis."; diff --git a/src/android/JadxSession.ts b/src/android/JadxSession.ts index 13f154b2e..7c413902e 100644 --- a/src/android/JadxSession.ts +++ b/src/android/JadxSession.ts @@ -69,8 +69,10 @@ export class JadxSession { readonly jarHash: string; readonly bridgeHash: string; readonly signal: AbortSignal; + /** Selected whole-operation deadline; also bounds each upstream call. */ + readonly timeoutMs: number; }): Promise { - const { request, target, snapshot, jarHash, signal } = context; + const { request, target, snapshot, jarHash, signal, timeoutMs } = context; this.#partialContext = { operation: request.operation, selectedPath: request.input.path, @@ -105,7 +107,7 @@ export class JadxSession { request.operation, `Expected ${JADX_BRIDGE_IDENTITY.name} ${JADX_BRIDGE_IDENTITY.version}; server reported ${server?.name ?? "unknown"} ${server?.version ?? "unknown"}.`, ); - const tools = this.#tools(request, target, signal); + const tools = this.#tools(request, target, signal, timeoutMs); const runtime = jadxRuntimeSchema.parse( await tools.json("rea_jvm_status", {}), ); @@ -206,15 +208,17 @@ export class JadxSession { request: AndroidRequest, target: BinaryTarget, signal: AbortSignal, + timeoutMs: number, ): JadxToolPort { const call = async ( name: string, input: Readonly>, ): Promise => { signal.throwIfAborted(); + // Keep an explicit bound: omitting it falls back to the SDK's 60-second default. const response = await this.#client.callTool( { name, arguments: input }, - { timeout: 120_000, signal }, + { timeout: timeoutMs, signal }, ); const observedResponse = jsonValueSchema.parse(response); const call = { operation: name, input, response: observedResponse }; diff --git a/tests/boundary/android/jadxConfiguration.test.ts b/tests/boundary/android/jadxConfiguration.test.ts index 7600ce44f..34bfebe06 100644 --- a/tests/boundary/android/jadxConfiguration.test.ts +++ b/tests/boundary/android/jadxConfiguration.test.ts @@ -387,3 +387,45 @@ it("tags unavailable diagnostics with the boundary failure kind", async () => { if (availability.status !== "unavailable") throw new Error("unreachable"); expect(availability.diagnostics["failure_kind"]).toBe("jar"); }); + +it("reports the effective operation deadline and rejects unusable values", async () => { + const root = await createTestTempDirectory("rea-jadx-deadline-"); + const jar = join(root, "jadx-headless-mcp.jar"); + const javaHome = await createFakeJdk(root); + await writeJadxJarInventory(jar); + const environment = { REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome }; + for (const [value, expected] of [ + [undefined, 120_000], + ["1", 1], + ["900000", 900_000], + ["2147483647", 2_147_483_647], + ] as const) + await expect( + inspectJadxAvailability({ + ...environment, + ...(value === undefined + ? {} + : { REA_JADX_OPERATION_TIMEOUT_MS: value }), + }), + ).resolves.toMatchObject({ + status: "available", + diagnostics: { operation_timeout_ms: expected }, + }); + // Node clamps timer delays above the signed 32-bit range to 1 ms. + for (const value of ["", "0", "-1", "1.5", "1e3", " 5", "abc", "2147483648"]) + await expect( + inspectJadxAvailability({ + ...environment, + REA_JADX_OPERATION_TIMEOUT_MS: value, + }), + ).resolves.toMatchObject({ + status: "unavailable", + code: "open_options_invalid", + reason: expect.stringContaining("REA_JADX_OPERATION_TIMEOUT_MS"), + diagnostics: { + setting: "REA_JADX_OPERATION_TIMEOUT_MS", + value, + failure_kind: "operation_timeout", + }, + }); +}); diff --git a/tests/boundary/android/jadxDoctor.test.ts b/tests/boundary/android/jadxDoctor.test.ts index e053f66b5..a4806f9f2 100644 --- a/tests/boundary/android/jadxDoctor.test.ts +++ b/tests/boundary/android/jadxDoctor.test.ts @@ -33,6 +33,7 @@ it("publishes REA_JADX_MCP_JAR as registration environment when the configured J ok: true, code: null, classification: "config_drift", + details: { operation_timeout_ms: 120_000 }, }, ], }); @@ -214,6 +215,54 @@ it("publishes validated JVM resource overrides in the registration environment", }); }); +it("publishes and reports a validated operation deadline", async () => { + const root = await createTestTempDirectory("rea-jadx-doctor-deadline-"); + const jar = join(root, "jadx-headless-mcp.jar"); + const javaHome = await createFakeJdk(root); + await writeJadxJarInventory(jar); + + const inspection = await inspectJadxProvider({ + REA_JADX_MCP_JAR: jar, + JAVA_HOME: javaHome, + REA_JADX_OPERATION_TIMEOUT_MS: "900000", + }); + + expect(inspection.registrationEnvironment).toEqual({ + REA_JADX_MCP_JAR: jar, + JAVA_HOME: javaHome, + REA_JADX_OPERATION_TIMEOUT_MS: "900000", + }); + expect(inspection.checks[0]).toMatchObject({ + ok: true, + details: { operation_timeout_ms: 900_000 }, + }); +}); + +it("guides deadline correction when REA_JADX_OPERATION_TIMEOUT_MS is invalid", async () => { + const root = await createTestTempDirectory("rea-jadx-doctor-bad-deadline-"); + const jar = join(root, "jadx-headless-mcp.jar"); + const javaHome = await createFakeJdk(root); + await writeJadxJarInventory(jar); + + const inspection = await inspectJadxProvider({ + REA_JADX_MCP_JAR: jar, + JAVA_HOME: javaHome, + REA_JADX_OPERATION_TIMEOUT_MS: "2147483648", + }); + + expect(inspection.available).toBe(false); + expect(inspection.registrationEnvironment).toEqual({}); + expect(inspection.checks[0]).toMatchObject({ + ok: false, + code: "open_options_invalid", + classification: "config_drift", + }); + expect(inspection.checks[0]?.remediation).toContain( + "REA_JADX_OPERATION_TIMEOUT_MS", + ); + expect(inspection.checks[0]?.remediation).not.toContain("REA_JADX_MCP_JAR"); +}); + it("guides JDK selection when the Java runtime is older than 17", async () => { const root = await createTestTempDirectory("rea-jadx-doctor-oldjdk-"); const jar = join(root, "jadx-headless-mcp.jar"); diff --git a/tests/boundary/process/jadxIntegration.test.ts b/tests/boundary/process/jadxIntegration.test.ts index b497cc6cf..774244ba6 100644 --- a/tests/boundary/process/jadxIntegration.test.ts +++ b/tests/boundary/process/jadxIntegration.test.ts @@ -1,20 +1,25 @@ -import { expect, it as test, vi } from "vitest"; +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"); @@ -668,6 +673,147 @@ it("cancels active and queued requests, cleans ownership and never launches a qu 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({}, () => { diff --git a/tests/fixtures/android/jadx.ts b/tests/fixtures/android/jadx.ts index 373f3a624..0e307492d 100644 --- a/tests/fixtures/android/jadx.ts +++ b/tests/fixtures/android/jadx.ts @@ -28,6 +28,7 @@ export const createJadxProtocolFixture = async ( mode = "normal", cleanupFailure?: () => boolean, inventory?: { readonly classCount?: number }, + settings: Readonly> = {}, ) => { const root = await createTestTempDirectory("rea-android-boundary-"); const apk = join(root, "fixture.apk"); @@ -52,10 +53,17 @@ export const createJadxProtocolFixture = async ( javaToolOptions: string | undefined; legacyJavaOptions: string | undefined; }[] = []; + // Inherit the host environment for process launch, but take JADX settings + // only from this fixture so a developer's shell cannot change the provider. const environment: NodeJS.ProcessEnv = { - ...process.env, + ...Object.fromEntries( + Object.entries(process.env).filter( + ([name]) => !name.startsWith("REA_JADX_"), + ), + ), JAVA_HOME: javaHome, REA_JADX_MCP_JAR: jar, + ...settings, }; const provider = createAndroidAnalysisProvider( environment,