fix(android): own Java prerequisite probing and readiness shutdown

Run the selected module-listing command through owned supervision, preserve completed module facts through finalization, and retain failed cleanup in provider/doctor scopes. Join complete availability calls on shutdown, preserve opaque interruption reasons, and validate numeric Java facts before reporting them.

Validation: 223 passing focused tests across 21 affected files, one platform skip; controlled baseline returns before child exit; real doctor cancellation leaves the selected Java group empty; nine real CLI and nine MCP tiny-APK inspections pass; six bounded MCP cost comparisons preserve normalized results. Native Windows/Linux and actual JVM failure modes remain unverified. Separate CLI exit disposition and archive failed-close findings remain open.

Closes #4845
This commit is contained in:
morluto
2026-10-18 02:00:29 +08:00
parent 964decd989
commit ceeff5a417
8 changed files with 1118 additions and 134 deletions
+44 -121
View File
@@ -1,20 +1,23 @@
import { throwIfJadxInterrupted } from "./JadxInterruption.js";
import { inspectJadxJava, type JadxJavaProbeOptions } from "./JadxJavaProbe.js";
import {
JadxInterruption,
throwIfJadxInterrupted,
} from "./JadxInterruption.js";
import { constants } from "node:fs";
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 {
AnalysisCancelledError,
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 {
execFileOutput,
execFileOutputFailure,
} from "../process/ExecFileOutput.js";
import type { DoctorProviderCheck } from "../application/Doctor.js";
import {
DEFAULT_JADX_OPERATION_TIMEOUT_MS,
@@ -27,8 +30,6 @@ import {
JADX_WINDOWS_NATIVE_REMEDIATION,
} from "./JadxRelease.js";
const JAVA_MODULE_LISTING_TIMEOUT_MS = 10_000;
const JAVA_MODULE_LISTING_MAX_OUTPUT_BYTES = 1024 * 1024;
// Bound ZIP metadata allocation during discovery. The audited 0.7.1 JAR's
// central directory occupies 2,294,350 bytes; class bodies are not read here.
const JAR_METADATA_READ_MAX_BYTES = 8 * 1024 * 1024;
@@ -164,15 +165,19 @@ class JadxConfigurationFailure extends Error {
/** Inspect the same JAR and Java prerequisites that gate actual execution. */
export const inspectJadxAvailability = async (
environment: Readonly<Record<string, string | undefined>>,
signal?: AbortSignal,
options: JadxJavaProbeOptions,
): Promise<ProviderAvailability> => {
try {
const operationTimeoutMs = readOperationTimeout(environment);
const configuration = await readJadxConfigurationInputs(
environment,
signal,
options.signal,
);
await validateJadxConfigurationContents(
configuration,
environment,
options,
);
await validateJadxConfigurationContents(configuration, environment, signal);
return {
status: "available",
code: null,
@@ -186,7 +191,11 @@ export const inspectJadxAvailability = async (
},
};
} catch (cause) {
if (signal?.aborted === true) throw cause;
if (cause instanceof JadxInterruption)
throw new AnalysisCancelledError(
options.operation ?? "inspect_jadx_configuration",
{ cause: cause.reason },
);
if (cause instanceof JadxConfigurationFailure)
return {
status: "unavailable",
@@ -223,7 +232,6 @@ export const resolveJadxConfigurationInputs = async (
try {
return await readJadxConfigurationInputs(environment, signal);
} catch (cause) {
if (signal?.aborted === true) throw cause;
if (!(cause instanceof JadxConfigurationFailure)) throw cause;
throw projectConfigurationFailure(operation, cause);
}
@@ -234,12 +242,14 @@ export const validateJadxConfiguration = async (
configuration: JadxConfiguration,
environment: Readonly<Record<string, string | undefined>>,
operation: AndroidOperation,
signal?: AbortSignal,
options: JadxJavaProbeOptions,
): Promise<void> => {
try {
await validateJadxConfigurationContents(configuration, environment, signal);
await validateJadxConfigurationContents(configuration, environment, {
...options,
operation,
});
} catch (cause) {
if (signal?.aborted === true) throw cause;
if (!(cause instanceof JadxConfigurationFailure)) throw cause;
throw projectConfigurationFailure(operation, cause);
}
@@ -261,11 +271,11 @@ const readJadxConfigurationInputs = async (
const validateJadxConfigurationContents = async (
configuration: JadxConfiguration,
environment: Readonly<Record<string, string | undefined>>,
signal?: AbortSignal,
options: JadxJavaProbeOptions,
): Promise<void> => {
throwIfJadxInterrupted(signal);
await inspectJavaRuntime(configuration.java, environment, signal);
await requireEngineArchive(configuration.jar, signal);
throwIfJadxInterrupted(options.signal);
await inspectJavaRuntime(configuration.java, environment, options);
await requireEngineArchive(configuration.jar, options.signal);
};
const projectConfigurationFailure = (
@@ -273,6 +283,7 @@ const projectConfigurationFailure = (
cause: JadxConfigurationFailure,
): AnalysisCapabilityUnavailableError => {
const output = cause.diagnostics;
const { stdout: _stdout, stderr: _stderr, ...facts } = output;
return new AnalysisCapabilityUnavailableError(
"jadx",
operation,
@@ -280,6 +291,11 @@ const projectConfigurationFailure = (
{
cause,
userMessage: cause.message,
diagnostics: {
...facts,
failure_kind: cause.kind,
failure_code: cause.code,
},
...(typeof output.stdout === "string" && typeof output.stderr === "string"
? {
capturedOutput: {
@@ -556,111 +572,18 @@ const readOperationTimeout = (
const inspectJavaRuntime = async (
java: string,
environment: Readonly<Record<string, string | undefined>>,
signal?: AbortSignal,
options: JadxJavaProbeOptions,
): Promise<void> => {
let stdout: string;
let stderr: string;
try {
({ stdout, stderr } = await execFileOutput(java, ["--list-modules"], {
env: { ...environment },
timeout: JAVA_MODULE_LISTING_TIMEOUT_MS,
maxBuffer: JAVA_MODULE_LISTING_MAX_OUTPUT_BYTES,
...(signal === undefined ? {} : { signal }),
}));
} catch (cause) {
if (signal?.aborted === true) throw cause;
const output = execFileOutputFailure(cause);
if (output === undefined) throw cause;
const diagnostics = {
java_executable: java,
phase: "module-inspection",
exit_code: typeof output.code === "number" ? output.code : null,
process_error_code: typeof output.code === "string" ? output.code : null,
signal: output.signal,
killed: output.killed,
stdout: output.stdout,
stderr: output.stderr,
output_truncated: output.outputTruncated,
} satisfies Readonly<Record<string, JsonValue>>;
if (output.outputTruncated)
throw configurationFailure(
"version_unresolved",
`The selected Java launcher ${java} exceeded the ${JAVA_MODULE_LISTING_MAX_OUTPUT_BYTES / (1024 * 1024)} MiB output budget while listing modules. REA retained partial output but could not establish compiler or version availability. Inspect the selected launcher for verbose or unbounded output; ` +
"java --list-modules should return a concise module listing.",
{
...diagnostics,
failure: "output_limit",
output_limit_bytes: JAVA_MODULE_LISTING_MAX_OUTPUT_BYTES,
},
"java_probe",
cause,
);
if (output.killed)
throw configurationFailure(
"version_unresolved",
`The selected Java launcher ${java} was terminated or did not finish the module-listing probe within ${JAVA_MODULE_LISTING_TIMEOUT_MS / 1000} seconds. Inspect the launcher and its startup responsiveness, then verify it returns compiler and version modules with java --list-modules.`,
{
...diagnostics,
failure: "probe_terminated",
deadline_ms: JAVA_MODULE_LISTING_TIMEOUT_MS,
},
"java_probe",
cause,
);
const result = await inspectJadxJava(java, environment, options);
if (!result.ok) {
const failure = result.error;
throw configurationFailure(
"runtime_missing",
`Cannot inspect selected Java runtime ${java}: ${output.stderr.trim() || errorText(cause)}. Select a full JDK including jdk.compiler via JAVA_HOME or PATH; verify it with java --list-modules.`,
diagnostics,
"java_runtime",
cause,
failure.code,
failure.message,
failure.diagnostics,
failure.kind,
);
}
const compiler = /^jdk\.compiler(?:@|$)/mu.test(stdout);
if (!compiler)
throw configurationFailure(
"runtime_missing",
`Selected Java runtime ${java} does not provide jdk.compiler. Select a full JDK including jdk.compiler via JAVA_HOME or PATH; verify it with java --list-modules. A JRE cannot compile REA's source bridge.`,
{
java_executable: java,
phase: "module-inspection",
jdk_compiler: false,
stdout,
stderr,
output_truncated: false,
},
"java_runtime",
);
const major = /^java\.base@(\d+)(?:[.+_-]|$)/mu.exec(stdout)?.[1];
if (major === undefined)
throw configurationFailure(
"version_unresolved",
`The selected Java launcher ${java} did not report a parseable java.base version in its module listing. Inspect the launcher output and verify it with java --list-modules.`,
{
java_executable: java,
phase: "module-inspection",
jdk_compiler: true,
java_major: null,
stdout,
stderr,
output_truncated: false,
},
"java_probe",
);
if (Number(major) < 17)
throw configurationFailure(
"unsupported_version",
`Selected Java runtime ${java} must be JDK 17 or newer and include jdk.compiler; verify it with java --list-modules.`,
{
java_executable: java,
phase: "module-inspection",
jdk_compiler: true,
java_major: major ?? null,
stdout,
stderr,
output_truncated: false,
},
"java_runtime",
);
};
const configurationFailure = (
+7 -1
View File
@@ -1,3 +1,4 @@
import type { FileCloseResourceOwner } from "../application/AnalysisProvider.js";
import type {
DoctorProviderCheck,
DoctorProviderInspection,
@@ -14,9 +15,14 @@ import {
/** Project the validated JADX configuration into the doctor contract. */
export const inspectJadxProvider = async (
environment: Readonly<Record<string, string | undefined>>,
resourceOwner: FileCloseResourceOwner,
signal?: AbortSignal,
): Promise<DoctorProviderInspection> => {
const configured = environment.REA_JADX_MCP_JAR !== undefined;
const availability = await inspectJadxAvailability(environment);
const availability = await inspectJadxAvailability(environment, {
resourceOwner,
signal,
});
// diagnostics is a loosely-typed JsonValue map; guard the published path
// even though the success path always sets jar_path today.
const jarPath =
+378
View File
@@ -0,0 +1,378 @@
import { randomUUID } from "node:crypto";
import type { FileCloseResourceOwner } from "../application/AnalysisProvider.js";
import {
AnalysisError,
type AnalysisCapturedOutput,
} from "../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisTimeoutError,
} from "../domain/analysisErrorCore.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import { err, ok, type Result } from "../domain/result.js";
import {
prepareOwnedCommand,
runOwnedCommand,
OwnedCommandFailure,
} from "../process/OwnedCommand.js";
import type { ProviderProcessSnapshot } from "../process/ProviderProcess.js";
import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js";
import { retainOwnedCommandCleanup } from "../providers/OwnedCommandCleanup.js";
import { analysisClientFailureForOperation } from "../providers/AnalysisClientFailure.js";
const JAVA_MODULE_LISTING_TIMEOUT_MS = 10_000;
const JAVA_MODULE_LISTING_MAX_OUTPUT_BYTES = 1024 * 1024;
export interface JadxJavaProbeOptions {
readonly resourceOwner: FileCloseResourceOwner;
readonly signal?: AbortSignal | undefined;
readonly operation?: string;
/** The Android operation owns this deadline; the probe also has its own bound. */
readonly operationDeadline?: {
readonly signal: AbortSignal;
readonly timeoutMs: number;
};
readonly launcher?: NonNullable<
Parameters<typeof runOwnedCommand>[2]
>["launcher"];
readonly timeoutMs?: number;
}
export interface JadxJavaProbeRefusal {
readonly code:
| "runtime_missing"
| "runtime_unavailable"
| "version_unresolved"
| "unsupported_version";
readonly message: string;
readonly diagnostics: Readonly<Record<string, JsonValue>>;
readonly kind: "java_probe" | "java_runtime";
}
interface JavaModules {
readonly compiler: true;
readonly major: number;
}
const refusal = (
code: JadxJavaProbeRefusal["code"],
message: string,
diagnostics: JadxJavaProbeRefusal["diagnostics"],
kind: JadxJavaProbeRefusal["kind"],
): Result<never, JadxJavaProbeRefusal> =>
err({ code, message, diagnostics, kind });
const capturedOutput = (
snapshot: ProviderProcessSnapshot,
): AnalysisCapturedOutput => ({
stdout: snapshot.stdout.text,
stderr: snapshot.stderr.text,
stdout_bytes: snapshot.stdout.observedBytes,
stderr_bytes: snapshot.stderr.observedBytes,
truncated:
snapshot.diagnosticTruncated === true || snapshot.outputIncomplete === true,
exit_code: snapshot.exitCode ?? null,
signal: snapshot.signal ?? null,
});
const projectRefusal = (
failure: JadxJavaProbeRefusal,
operation: string,
cause: unknown,
output?: AnalysisCapturedOutput,
) => {
const { stdout: _stdout, stderr: _stderr, ...facts } = failure.diagnostics;
return new AnalysisCapabilityUnavailableError(
"jadx",
operation,
failure.message,
{
cause,
userMessage: failure.message,
diagnostics: {
...facts,
failure_kind: failure.kind,
failure_code: failure.code,
},
...(output === undefined ? {} : { capturedOutput: output }),
},
);
};
/** Interpret one complete module listing before joining final cleanup. */
export const inspectJadxJava = async (
java: string,
environment: Readonly<Record<string, string | undefined>>,
options: JadxJavaProbeOptions,
): Promise<Result<JavaModules, JadxJavaProbeRefusal>> => {
const operation = options.operation ?? "inspect_jadx_configuration";
try {
return await options.resourceOwner.run(async () => {
let observed: Result<JavaModules, JadxJavaProbeRefusal> | undefined;
try {
await prepareOwnedCommand(options.signal);
await runOwnedCommand(
{
command: java,
arguments: ["--list-modules"],
runId: randomUUID(),
expectedCommand: null,
hostEnvironment: environment,
sweepTokenOwnedProcesses: true,
},
{
timeoutMs: options.timeoutMs ?? JAVA_MODULE_LISTING_TIMEOUT_MS,
diagnosticBytes: JAVA_MODULE_LISTING_MAX_OUTPUT_BYTES,
acceptNonZeroExit: true,
observeCompleted: (snapshot) => {
observed = parseJavaModules(java, snapshot);
},
},
{
signal: options.signal,
...(options.launcher === undefined
? {}
: { launcher: options.launcher }),
},
);
if (observed === undefined)
throw new Error("Java probe completed without an observation");
return observed;
} catch (cause: unknown) {
const command =
cause instanceof OwnedCommandFailure ? cause : undefined;
const preparation =
command?.preparationFailure ??
(cause instanceof DarwinProcessOwnershipInspectionError
? cause
: undefined);
const output =
command?.snapshot == null
? preparation?.capturedOutput
: capturedOutput(command.snapshot);
const cleanups = retainOwnedCommandCleanup(
cause,
options.resourceOwner,
"jadx",
operation,
);
let primary: AnalysisError;
if (observed !== undefined && !observed.ok) {
if (cleanups.length === 0) return observed;
primary = projectRefusal(observed.error, operation, cause, output);
} else if (
command?.reason === "cancelled" &&
command.cancellation !== undefined
) {
const reason = command.cancellation.reason;
const errorOptions = {
cause: reason,
...(output === undefined ? {} : { capturedOutput: output }),
...(observed?.ok === true
? {
diagnostics: {
java_module_observation: {
compiler: observed.value.compiler,
major: observed.value.major,
},
},
}
: {}),
};
primary =
options.operationDeadline?.signal.aborted === true &&
reason === options.operationDeadline.signal.reason
? new AnalysisTimeoutError(
operation,
options.operationDeadline.timeoutMs,
errorOptions,
)
: new AnalysisCancelledError(operation, errorOptions);
} else if (
observed?.ok === true &&
command?.cleanupOwner !== undefined
) {
primary = new ProviderAdapterError("jadx", operation, {
cause,
...(output === undefined ? {} : { capturedOutput: output }),
diagnostics: {
stage: "cleanup",
java_module_observation: {
compiler: observed.value.compiler,
major: observed.value.major,
},
},
});
} else if (preparation !== undefined) {
primary = new ProviderAdapterError("jadx", operation, {
cause,
...(output === undefined ? {} : { capturedOutput: output }),
diagnostics: {
stage: "ownership_preparation",
...(preparation.compilerOutcome === undefined
? {}
: {
compiler_outcome: jsonValueSchema.parse(
preparation.compilerOutcome,
),
}),
},
});
} else if (cause instanceof AnalysisError) {
primary = cause;
} else {
const failure = commandProbeRefusal(
java,
command,
cause,
options.timeoutMs ?? JAVA_MODULE_LISTING_TIMEOUT_MS,
);
if (cleanups.length === 0) return err(failure);
primary = projectRefusal(failure, operation, cause, output);
}
for (const cleanup of cleanups)
primary = analysisErrorWithCleanupFailure(
primary,
cleanup,
operation,
).bindCleanupDependencies([primary, cleanup]);
throw primary;
}
});
} catch (cause: unknown) {
if (cause instanceof AnalysisError) throw cause;
throw analysisClientFailureForOperation(cause, "jadx", operation);
}
};
const commandProbeRefusal = (
java: string,
command:
| Pick<OwnedCommandFailure, "snapshot" | "reason" | "processError">
| undefined,
cause: unknown,
timeoutMs: number,
): JadxJavaProbeRefusal => {
const output =
command?.snapshot == null ? undefined : capturedOutput(command.snapshot);
const processError =
command?.processError ?? (cause instanceof Error ? cause : undefined);
const systemCode =
processError === undefined ? undefined : Reflect.get(processError, "code");
const limited = command?.reason === "output-limit";
const timedOut = command?.reason === "timeout";
return {
code:
systemCode === "ENOENT"
? "runtime_missing"
: limited || timedOut
? "version_unresolved"
: "runtime_unavailable",
kind: systemCode === "ENOENT" ? "java_runtime" : "java_probe",
message: limited
? `The selected Java launcher ${java} exceeded the ${JAVA_MODULE_LISTING_MAX_OUTPUT_BYTES / (1024 * 1024)} MiB output budget while listing modules. REA retained partial output but could not establish compiler or version availability. Inspect the selected launcher for verbose or unbounded output; java --list-modules should return a concise module listing.`
: timedOut
? `The selected Java launcher ${java} did not finish the module-listing probe within ${timeoutMs / 1000} seconds. Inspect its startup responsiveness and verify it with java --list-modules.`
: `Cannot inspect selected Java runtime ${java}: ${systemCode === "ENOENT" ? "spawn java ENOENT" : cause instanceof Error ? cause.message : cause === undefined ? `exit ${String(output?.exit_code ?? "unknown")}${output?.signal == null ? "" : ` (${output.signal})`}` : String(cause)}. Review the selected launcher and retained diagnostics.`,
diagnostics: {
java_executable: java,
phase: "module-inspection",
exit_code: output?.exit_code ?? null,
process_error_code: typeof systemCode === "string" ? systemCode : null,
signal: output?.signal ?? null,
killed: null,
stdout: output?.stdout ?? "",
stderr: output?.stderr ?? "",
output_truncated: output?.truncated ?? true,
...(limited
? {
failure: "output_limit",
output_limit_bytes: JAVA_MODULE_LISTING_MAX_OUTPUT_BYTES,
}
: {}),
...(timedOut
? { failure: "probe_terminated", deadline_ms: timeoutMs }
: {}),
},
};
};
const parseJavaModules = (
java: string,
snapshot: ProviderProcessSnapshot,
): Result<JavaModules, JadxJavaProbeRefusal> => {
if (snapshot.exitCode !== 0 || snapshot.signal !== null)
return err(
commandProbeRefusal(
java,
{ reason: "process", snapshot, processError: undefined },
undefined,
JAVA_MODULE_LISTING_TIMEOUT_MS,
),
);
const stdout = snapshot.stdout.text;
const stderr = snapshot.stderr.text;
const compiler = /^jdk\.compiler(?:@|$)/mu.test(stdout);
if (!compiler)
return refusal(
"runtime_missing",
`Selected Java runtime ${java} does not provide jdk.compiler. Select a full JDK including jdk.compiler via JAVA_HOME or PATH; verify it with java --list-modules. A JRE cannot compile REA's source bridge.`,
{
java_executable: java,
phase: "module-inspection",
jdk_compiler: false,
stdout,
stderr,
output_truncated: false,
},
"java_runtime",
);
const major = /^java\.base@(\d+)(?:[.+_-]|$)/mu.exec(stdout)?.[1];
if (major === undefined)
return refusal(
"version_unresolved",
`The selected Java launcher ${java} did not report a parseable java.base version in its module listing. Inspect the launcher output and verify it with java --list-modules.`,
{
java_executable: java,
phase: "module-inspection",
jdk_compiler: true,
java_major: null,
stdout,
stderr,
output_truncated: false,
},
"java_probe",
);
const majorNumber = Number(major);
if (!Number.isSafeInteger(majorNumber) || majorNumber <= 0)
return refusal(
"version_unresolved",
`The selected Java launcher ${java} reported an invalid java.base major version. Review the retained module listing.`,
{
java_executable: java,
phase: "module-inspection",
jdk_compiler: true,
java_major: null,
unparsed_java_major: major,
stdout,
stderr,
output_truncated: false,
},
"java_probe",
);
if (majorNumber < 17)
return refusal(
"unsupported_version",
`Selected Java runtime ${java} must be JDK 17 or newer and include jdk.compiler; verify it with java --list-modules.`,
{
java_executable: java,
phase: "module-inspection",
jdk_compiler: true,
java_major: major ?? null,
stdout,
stderr,
output_truncated: false,
},
"java_runtime",
);
return ok({ compiler: true, major: majorNumber });
};
+52 -2
View File
@@ -1,3 +1,7 @@
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
import type { FileCloseResourceOwner } from "../application/AnalysisProvider.js";
import { analysisClientFailureForOperation } from "../providers/AnalysisClientFailure.js";
import { throwIfJadxInterrupted } from "./JadxInterruption.js";
import { chmod, copyFile } from "node:fs/promises";
import { join } from "node:path";
@@ -242,6 +246,19 @@ export class JadxProvider implements AndroidAnalysisPort {
#retained: RetainedSession | undefined;
#idle: NodeJS.Timeout | undefined;
readonly #shutdown = new AbortController();
readonly #prerequisites = new ArtifactResourceScope();
readonly #inspections = new Set<
Promise<Awaited<ReturnType<typeof inspectJadxAvailability>>>
>();
readonly #prerequisiteOwner: FileCloseResourceOwner = {
retain: (owner, resource) =>
this.#prerequisites.retain({
kind: "lease",
resource,
lease: AnalysisCleanupLease.forCloseOwner(owner, resource),
}),
run: (operation) => this.#prerequisites.run(operation),
};
#closePromise: Promise<void> | undefined;
constructor(
readonly environment: Readonly<Record<string, string | undefined>>,
@@ -250,7 +267,19 @@ export class JadxProvider implements AndroidAnalysisPort {
/** Report actual local prerequisites without starting JADX or opening a target. */
inspectAvailability(signal?: AbortSignal) {
return inspectJadxAvailability(this.environment, signal);
const pending = inspectJadxAvailability(this.environment, {
resourceOwner: this.#prerequisiteOwner,
signal:
signal === undefined
? this.#shutdown.signal
: AbortSignal.any([signal, this.#shutdown.signal]),
});
this.#inspections.add(pending);
void pending.then(
() => this.#inspections.delete(pending),
() => this.#inspections.delete(pending),
);
return pending;
}
/** Serialize expensive work and bind both raw and normalized results to the APK. */
@@ -310,7 +339,24 @@ export class JadxProvider implements AndroidAnalysisPort {
clearTimeout(this.#idle);
this.#closePromise ??= this.#tail
.then(async () => {
await Promise.allSettled(this.#inspections);
const retired = await this.#retire();
try {
await this.#prerequisites.close();
} catch (cause: unknown) {
const cleanup = analysisClientFailureForOperation(
cause,
"jadx",
"close_binary",
);
if (!retired.ok)
throw analysisErrorWithCleanupFailure(
retired.error,
cleanup,
"close_binary",
).bindCleanupDependencies([retired.error, cleanup]);
throw cleanup;
}
if (!retired.ok) throw retired.error;
})
.catch((cause: unknown) => {
@@ -453,7 +499,11 @@ export class JadxProvider implements AndroidAnalysisPort {
configuration,
this.environment,
request.operation,
signal,
{
resourceOwner: this.#prerequisiteOwner,
signal,
operationDeadline: { signal: timeout, timeoutMs: timeoutMs.value },
},
);
root = await createJadxWorkspaceRoot();
const workspaceSignal =
+5 -1
View File
@@ -51,7 +51,11 @@ export const createSystemDoctorHost = (
inspectionSignal,
),
inspectIdaRegistration(environment.REA_IDA_MCP_CONFIG),
await inspectJadxProvider(environment),
await inspectJadxProvider(
environment,
fileCloseResources,
inspectionSignal,
),
await inspectKunaProvider(environment, process.platform, {
fileCloseResources,
}),
@@ -1,10 +1,12 @@
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
import { createInspectionResources } from "../../fixtures/inspectionResources.js";
import { chmod, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it as test } from "vitest";
import { expect, it as test, onTestFinished } from "vitest";
import {
inspectJadxAvailability,
inspectJadxAvailability as inspectAvailability,
resolveJadxConfigurationInputs,
validateJadxConfiguration,
} from "../../../src/android/JadxConfiguration.js";
@@ -13,6 +15,20 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { writeOrderedZip } from "../../fixtures/artifactEntryOrder.js";
import { writeJadxJarInventory } from "../../fixtures/android/jadxJar.js";
const inspectionOwner = () => {
const resources = createInspectionResources();
onTestFinished(() => resources.close());
return resources.owner;
};
const inspectJadxAvailability = (
environment: Parameters<typeof inspectAvailability>[0],
signal?: AbortSignal,
) =>
inspectAvailability(environment, {
resourceOwner: inspectionOwner(),
signal,
});
const it = test.skipIf(process.platform === "win32");
const resolveConfiguration = async (
@@ -23,7 +39,9 @@ const resolveConfiguration = async (
environment,
operation,
);
await validateJadxConfiguration(configuration, environment, operation);
await validateJadxConfiguration(configuration, environment, operation, {
resourceOwner: inspectionOwner(),
});
return configuration;
};
@@ -248,8 +266,7 @@ it("preserves exact Java probe output and marks max-buffer capture as truncated"
status: "unavailable",
code: "version_unresolved",
diagnostics: {
exit_code: null,
process_error_code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER",
process_error_code: null,
failure: "output_limit",
output_truncated: true,
},
@@ -320,7 +337,9 @@ it("cancels an in-flight Java readiness probe", async () => {
)
.toBe(true);
controller.abort();
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
await expect(pending).rejects.toMatchObject({
_tag: "AnalysisCancelledError",
});
} finally {
controller.abort();
await pending.catch(() => undefined);
@@ -344,7 +363,7 @@ it("reports a killed Java probe as unresolved instead of missing runtime", async
diagnostics: {
java_executable: java,
failure: "probe_terminated",
killed: true,
killed: null,
deadline_ms: 10_000,
},
});
@@ -429,3 +448,103 @@ it("reports the effective operation deadline and rejects unusable values", async
},
});
});
it("joins a cancelled Java readiness probe until its owned child exits", async () => {
const root = await createTestTempDirectory("rea-jadx-probe-finalization-");
const javaHome = join(root, "jdk");
const java = join(javaHome, "bin", "java");
const jar = join(root, "engine.jar");
const ready = join(root, "ready");
const stopping = join(root, "stopping");
const release = join(root, "release");
const exiting = join(root, "exiting");
await mkdir(join(javaHome, "bin"), { recursive: true });
await writeJadxJarInventory(jar);
await writeFile(
java,
`#!${process.execPath}
const fs = require("node:fs");
const ready = ${JSON.stringify(ready)};
const stopping = ${JSON.stringify(stopping)};
const release = ${JSON.stringify(release)};
const exiting = ${JSON.stringify(exiting)};
process.on("SIGTERM", () => { fs.writeFileSync(stopping, "received"); });
const check = setInterval(() => {
if (fs.existsSync(release)) {
fs.writeFileSync(exiting, "released");
process.stderr.write("final readiness diagnostic");
clearInterval(check);
clearTimeout(watchdog);
}
}, 10);
const watchdog = setTimeout(() => { process.exitCode = 99; clearInterval(check); }, 10000);
fs.writeFileSync(ready, String(process.pid));
`,
);
await chmod(java, 0o700);
const caller = new AbortController();
const pending = inspectJadxAvailability(
{ JAVA_HOME: javaHome, REA_JADX_MCP_JAR: jar },
caller.signal,
).then(
(value) => ({ value }),
(error: unknown) => ({ error }),
);
const fileExists = (path: string) =>
readFile(path, "utf8").then(
() => true,
() => false,
);
let pid: number | undefined;
const absent = () => {
if (pid === undefined) return true;
try {
process.kill(pid, 0);
return false;
} catch (cause: unknown) {
if (cause instanceof Error && "code" in cause && cause.code === "ESRCH")
return true;
throw cause;
}
};
try {
await expect.poll(() => fileExists(ready)).toBe(true);
pid = Number(await readFile(ready, "utf8"));
caller.abort({ caller: "readiness cancellation" });
const outcome = await pending;
expect(outcome).toMatchObject({ error: { cause: caller.signal.reason } });
// A zero-signal observation is conservative under PID reuse and never
// grants cleanup authority; only the fixture's release file requests exit.
expect(absent()).toBe(true);
} finally {
caller.abort();
await writeFile(release, "release");
await pending;
await expect.poll(absent).toBe(true);
}
});
it.each([
null,
{ caller: "before readiness" },
new ProviderCleanupError("foreign", ["foreign-resource"], {
reason: "foreign cleanup",
}),
])(
"preserves opaque pre-admission readiness cancellation %j",
async (reason) => {
const caller = new AbortController();
caller.abort(reason);
const error: unknown = await inspectJadxAvailability(
{},
caller.signal,
).catch((cause: unknown) => cause);
expect(error).toMatchObject({
_tag: "AnalysisCancelledError",
cause: reason,
cleanupIncomplete: false,
});
if (reason instanceof ProviderCleanupError)
expect(reason.cleanupIncomplete).toBe(true);
},
);
+12 -2
View File
@@ -1,13 +1,23 @@
import { createInspectionResources } from "../../fixtures/inspectionResources.js";
import { chmod, mkdir, realpath, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it as test } from "vitest";
import { expect, it as test, onTestFinished } from "vitest";
import { inspectJadxProvider } from "../../../src/android/JadxDoctor.js";
import { inspectJadxProvider as inspectProvider } from "../../../src/android/JadxDoctor.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { writeJadxJarInventory } from "../../fixtures/android/jadxJar.js";
import { writeOrderedZip } from "../../fixtures/artifactEntryOrder.js";
const inspectionOwner = () => {
const resources = createInspectionResources();
onTestFinished(() => resources.close());
return resources.owner;
};
const inspectJadxProvider = (
environment: Parameters<typeof inspectProvider>[0],
) => inspectProvider(environment, inspectionOwner());
const it = test.skipIf(process.platform === "win32");
it("publishes REA_JADX_MCP_JAR as registration environment when the configured JAR validates", async () => {
@@ -0,0 +1,494 @@
import { ZipArtifactReader } from "../../../src/artifacts/ZipArtifactReader.js";
import { beforeAll, expect, it as test, vi } from "vitest";
import { inspectJadxJava } from "../../../src/android/JadxJavaProbe.js";
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { AnalysisError } from "../../../src/domain/analysisErrorBase.js";
import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js";
import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js";
import {
observeOwnedProcessGroups,
prepareProcessOwnershipInspection,
} from "../../../src/process/ProcessOwnershipObservation.js";
import { createInspectionResources } from "../../fixtures/inspectionResources.js";
import { writeFile, readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { createJadxProtocolFixture } from "../../fixtures/android/jadx.js";
import { inspectJadxProvider } from "../../../src/android/JadxDoctor.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
// Real idle Node commands exercise ownership; they do not establish JVM behavior.
beforeAll(() => prepareProcessOwnershipInspection());
const it = test.skipIf(process.platform === "win32");
type Launched = Awaited<ReturnType<typeof spawnOwnedProviderProcess>>;
const modules = "java.base@21\njdk.compiler@21\n";
const expectEmpty = async (launched: Launched) => {
const observed = await observeOwnedProcessGroups(launched.ownership.runId, [
launched.ownership.processGroupId,
]);
expect(observed.get(launched.ownership.processGroupId)).toEqual({
state: "empty",
});
};
it.each([
null,
{ caller: "opaque" },
new ProviderCleanupError("foreign", ["/foreign/resource"], {
reason: "foreign cleanup",
}),
])(
"joins active Java cancellation with exact opaque reason %j",
async (reason) => {
const resources = createInspectionResources();
const caller = new AbortController();
let launched: Launched | undefined;
try {
const pending = inspectJadxJava(
process.execPath,
{},
{
resourceOwner: resources.owner,
signal: caller.signal,
timeoutMs: 2500,
launcher: async (input) => {
launched = await spawnOwnedProviderProcess({
...input,
arguments: [
"-e",
'process.stdout.write("READY"); setInterval(() => {}, 1000)',
],
});
launched.process.stdout?.once("data", () => caller.abort(reason));
return launched;
},
},
);
const error: unknown = await pending.catch((cause: unknown) => cause);
expect(error).toMatchObject({
_tag: "AnalysisCancelledError",
cleanupIncomplete: false,
});
if (!(error instanceof AnalysisError) || launched === undefined)
throw new Error("Expected owned cancellation");
expect(error.cause).toBe(reason);
expect(error.cleanupResources).not.toContain("/foreign/resource");
if (reason instanceof ProviderCleanupError)
expect(reason.cleanupIncomplete).toBe(true);
await expectEmpty(launched);
} finally {
caller.abort(reason);
await resources.close();
if (launched !== undefined)
expect(
(await cleanupOwnedProcessGroup(launched.ownership)).cleaned,
).toBe(true);
}
},
);
it.each([true, false])(
"keeps completed Java module facts across cleanup cancellation (valid: %s)",
async (valid) => {
const resources = createInspectionResources();
const caller = new AbortController();
const reason = { caller: "cleanup interruption" };
let launched: Launched | undefined;
try {
const result: unknown = await inspectJadxJava(
process.execPath,
{},
{
resourceOwner: resources.owner,
signal: caller.signal,
timeoutMs: 2500,
launcher: async (input) => {
const child = await spawnOwnedProviderProcess({
...input,
arguments: [
"-e",
`process.stdout.write(${JSON.stringify(valid ? modules : "java.base@21\n")})`,
],
});
launched = child;
return {
...child,
cleanup: async () => {
const cleaned = await cleanupOwnedProcessGroup(child.ownership);
caller.abort(reason);
return cleaned;
},
};
},
},
).catch((cause: unknown) => cause);
if (valid) {
expect(result).toMatchObject({
_tag: "AnalysisCancelledError",
cause: reason,
diagnostics: {
java_module_observation: { compiler: true, major: 21 },
},
capturedOutput: { stdout: modules, truncated: false },
});
} else {
expect(result).toMatchObject({
ok: false,
error: {
code: "runtime_missing",
kind: "java_runtime",
diagnostics: { jdk_compiler: false, stdout: "java.base@21\n" },
},
});
}
if (launched === undefined) throw new Error("Expected a launched probe");
await expectEmpty(launched);
} finally {
await resources.close();
if (launched !== undefined)
expect(
(await cleanupOwnedProcessGroup(launched.ownership)).cleaned,
).toBe(true);
}
},
);
it.each([true, false])(
"retains cleanup and blocks replacement probes until exact retry (valid: %s)",
async (valid) => {
const resources = createInspectionResources();
let failing = true;
let launches = 0;
let launched: Launched | undefined;
const probe = () =>
inspectJadxJava(
process.execPath,
{},
{
resourceOwner: resources.owner,
timeoutMs: 2500,
launcher: async (input) => {
launches++;
const child = await spawnOwnedProviderProcess({
...input,
arguments: [
"-e",
`process.stdout.write(${JSON.stringify(valid ? modules : "java.base@21\n")})`,
],
});
launched = child;
return {
...child,
cleanup: async () =>
failing
? {
cleaned: false as const,
reason: "controlled ownership verification failure",
}
: cleanupOwnedProcessGroup(child.ownership),
};
},
},
);
try {
const error: unknown = await probe().catch((cause: unknown) => cause);
if (!(error instanceof AnalysisError))
throw new Error("Expected retained cleanup error");
expect(error.cleanupIncomplete).toBe(true);
expect(error.cleanupResources.length).toBeGreaterThan(0);
if (valid)
expect(error.cleanupPrimary).toMatchObject({
diagnostics: {
java_module_observation: { major: 21, compiler: true },
},
});
else
expect(error.cleanupPrimary).toMatchObject({
_tag: "AnalysisCapabilityUnavailableError",
});
const retry: unknown = await probe().catch((cause: unknown) => cause);
expect(retry).toBeInstanceOf(AnalysisError);
if (retry instanceof AnalysisError)
expect(retry.cleanupResources).toEqual(error.cleanupResources);
expect(launches).toBe(1);
failing = false;
const recovered = await probe();
expect(recovered.ok).toBe(valid);
expect(launches).toBe(2);
expect(error.cleanupIncomplete).toBe(false);
expect(error.cleanupResources).toEqual([]);
if (!valid)
expect(projectAnalysisError(error).code).toBe("capability_unavailable");
if (!valid)
expect(error.cleanupPrimary?.diagnostics).toMatchObject({
jdk_compiler: false,
phase: "module-inspection",
});
} finally {
failing = false;
await resources.close();
if (launched !== undefined)
expect(
(await cleanupOwnedProcessGroup(launched.ownership)).cleaned,
).toBe(true);
}
},
);
it("uses the operation deadline's exact reason while joining the Java probe", async () => {
const resources = createInspectionResources();
const deadline = new AbortController();
const reason = new Error("whole Android operation deadline");
let launched: Launched | undefined;
try {
const outcome: unknown = await inspectJadxJava(
process.execPath,
{},
{
resourceOwner: resources.owner,
signal: deadline.signal,
operation: "inspect_android_package",
operationDeadline: { signal: deadline.signal, timeoutMs: 2000 },
timeoutMs: 2500,
launcher: async (input) => {
launched = await spawnOwnedProviderProcess({
...input,
arguments: [
"-e",
'process.stdout.write("READY"); setInterval(() => {}, 1000)',
],
});
launched.process.stdout?.once("data", () => deadline.abort(reason));
return launched;
},
},
).catch((cause: unknown) => cause);
expect(outcome).toMatchObject({
_tag: "AnalysisTimeoutError",
cause: reason,
operation: "inspect_android_package",
});
if (launched === undefined)
throw new Error("Expected owned deadline probe");
await expectEmpty(launched);
} finally {
await resources.close();
if (launched !== undefined)
expect((await cleanupOwnedProcessGroup(launched.ownership)).cleaned).toBe(
true,
);
}
});
it.each(["0", "9007199254740993", "9".repeat(400)])(
"refuses an unrepresentable Java major %s without losing its source value",
async (major) => {
const resources = createInspectionResources();
let launched: Launched | undefined;
try {
const result = await inspectJadxJava(
process.execPath,
{},
{
resourceOwner: resources.owner,
timeoutMs: 2500,
launcher: async (input) => {
launched = await spawnOwnedProviderProcess({
...input,
arguments: [
"-e",
`process.stdout.write(${JSON.stringify(`java.base@${major}\njdk.compiler@21\n`)})`,
],
});
return launched;
},
},
);
expect(result).toMatchObject({
ok: false,
error: {
code: "version_unresolved",
diagnostics: { java_major: null, unparsed_java_major: major },
},
});
} finally {
await resources.close();
if (launched !== undefined)
expect(
(await cleanupOwnedProcessGroup(launched.ownership)).cleaned,
).toBe(true);
}
},
);
it("joins concurrent provider readiness on shutdown while an independent doctor probe remains owned", async () => {
const f = await createJadxProtocolFixture();
const resources = createInspectionResources();
const doctor = new AbortController();
const marker = join(dirname(f.apk), "readiness-processes");
const java = join(f.environment.JAVA_HOME!, "bin", "java");
await writeFile(
java,
String.raw`#!${process.execPath}
const fs = require("node:fs");
fs.appendFileSync(${JSON.stringify(marker)}, String(process.pid) + "\n");
process.stdout.write(String(process.pid));
process.on("SIGTERM", () => {});
setTimeout(() => process.exit(99), 10000);
`,
);
const first = f.provider
.inspectAvailability()
.catch((cause: unknown) => cause);
const second = f.provider
.inspectAvailability()
.catch((cause: unknown) => cause);
let doctorSettled = false;
const independent = inspectJadxProvider(
f.environment,
resources.owner,
doctor.signal,
).then(
(value) => {
doctorSettled = true;
return value;
},
(cause: unknown) => {
doctorSettled = true;
return cause;
},
);
const pids = () =>
readFile(marker, "utf8").then(
(value) => value.trim().split("\n").map(Number),
() => [],
);
try {
await expect.poll(async () => (await pids()).length).toBe(3);
await f.provider.close();
const cancelled = await Promise.all([first, second]);
for (const error of cancelled)
expect(error).toMatchObject({ _tag: "AnalysisCancelledError" });
expect(doctorSettled).toBe(false);
doctor.abort("stop independent doctor");
expect(await independent).toMatchObject({
_tag: "AnalysisCancelledError",
cause: "stop independent doctor",
});
for (const pid of await pids()) {
// Observation only; cleanup remains with each command's run identity.
expect(() => process.kill(pid, 0)).toThrowError(
expect.objectContaining({ code: "ESRCH" }),
);
}
} finally {
doctor.abort();
await f.provider.close();
await resources.close();
await Promise.all([first, second, independent]);
}
});
it("refuses a root's valid listing until an inherited descendant pipe is finalized", async () => {
const resources = createInspectionResources();
const root = await createTestTempDirectory("rea-jadx-readiness-descendant-");
const release = join(root, "release");
const child = String.raw`const fs = require("node:fs"); process.stdout.write("DESCENDANT_READY\n");
setInterval(() => { if (fs.existsSync(${JSON.stringify(release)})) process.exit(0); }, 50);
setTimeout(() => process.exit(92), 10000);`;
let launched: Launched | undefined;
try {
const result = await inspectJadxJava(
process.execPath,
{},
{
resourceOwner: resources.owner,
timeoutMs: 1000,
launcher: async (input) => {
launched = await spawnOwnedProviderProcess({
...input,
arguments: [
"-e",
String.raw`
const child = require("node:child_process").spawn(process.execPath, ["-e", ${JSON.stringify(child)}], {detached: true, stdio: ["ignore", "inherit", "inherit"]});
process.stdout.write(${JSON.stringify(modules)} + "DESCENDANT_GROUP:" + child.pid + "\n"); child.unref();
`,
],
});
return launched;
},
},
);
expect(result).toMatchObject({
ok: false,
error: {
code: "version_unresolved",
diagnostics: { failure: "probe_terminated", exit_code: 0 },
},
});
if (result.ok || launched === undefined)
throw new Error("Expected incomplete-output deadline");
const group = Number(
/DESCENDANT_GROUP:(\d+)/u.exec(
String(result.error.diagnostics.stdout),
)?.[1],
);
expect(Number.isSafeInteger(group)).toBe(true);
const observed = await observeOwnedProcessGroups(launched.ownership.runId, [
group,
]);
expect(observed.get(group)).toEqual({ state: "empty" });
await expectEmpty(launched);
} finally {
await writeFile(release, "release");
await resources.close();
if (launched !== undefined)
expect(
(
await (launched.cleanup?.() ??
cleanupOwnedProcessGroup(launched.ownership))
).cleaned,
).toBe(true);
}
});
it("joins the whole availability call while its final archive close is held", async () => {
const f = await createJadxProtocolFixture();
let started: () => void = () => {};
const closingArchive = new Promise<void>((resolve) => {
started = resolve;
});
let release: () => void = () => {};
const held = new Promise<void>((resolve) => {
release = resolve;
});
const original = ZipArtifactReader.prototype.close;
const archive = vi
.spyOn(ZipArtifactReader.prototype, "close")
.mockImplementationOnce(async function (this: ZipArtifactReader) {
started();
await held;
await original.call(this);
});
const availability = f.provider.inspectAvailability();
let completed = false;
let close: Promise<void> | undefined;
try {
await closingArchive;
close = f.provider.close().then(() => {
completed = true;
});
await new Promise<void>((resolve) => setImmediate(resolve));
expect(completed).toBe(false);
release();
await close;
expect(await availability).toMatchObject({ status: "available" });
} finally {
release();
archive.mockRestore();
await availability.catch(() => undefined);
await close;
await f.provider.close();
}
});