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>
This commit is contained in:
Paritosh LahreandClaude Opus 5.5 authored and GitHub committed 2026-10-17 00:06:32 +08:00
1 parent 29b8769d2c
commit f433786463
12 files changed
+356 -14

No files matched your search

+44
View File
@@ -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<ProviderAvailability> => {
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<Record<string, string | undefined>>,
operation: AndroidOperation,
): Result<number, AnalysisCapabilityUnavailableError> => {
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<Record<string, string | undefined>>,
@@ -508,6 +537,21 @@ const resolveJvmArguments = (
return result;
};
const readOperationTimeout = (
environment: Readonly<Record<string, string | undefined>>,
): 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<Record<string, string | undefined>>,
+8
View File
@@ -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
+15 -5
View File
@@ -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<Outcome> {
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,
});
+6
View File
@@ -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.";
+7 -3
View File
@@ -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<AnalysisExecution> {
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<Record<string, JsonValue>>,
): Promise<string> => {
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 };