mirror of
https://github.com/morluto/rea.git
synced 2026-10-19 04:19:54 -05:00
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:
1 parent
29b8769d2c
commit
f433786463
12 files changed
+356
-14
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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({}, () => {
|
||||
|
||||
Vendored
+9
-1
@@ -28,6 +28,7 @@ export const createJadxProtocolFixture = async (
|
||||
mode = "normal",
|
||||
cleanupFailure?: () => boolean,
|
||||
inventory?: { readonly classCount?: number },
|
||||
settings: Readonly<Record<string, string>> = {},
|
||||
) => {
|
||||
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,
|
||||
|
||||
Reference in new issue
Block a user