mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
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:
@@ -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 = (
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user