From ceeff5a41788e47e5aee96a4baf191a6ce7fde4b Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:00:29 +0800 Subject: [PATCH] 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 --- src/android/JadxConfiguration.ts | 165 ++---- src/android/JadxDoctor.ts | 8 +- src/android/JadxJavaProbe.ts | 378 ++++++++++++++ src/android/JadxProvider.ts | 54 +- src/doctorRuntime.ts | 6 +- .../android/jadxConfiguration.test.ts | 133 ++++- tests/boundary/android/jadxDoctor.test.ts | 14 +- .../process/jadxJavaLifecycle.test.ts | 494 ++++++++++++++++++ 8 files changed, 1118 insertions(+), 134 deletions(-) create mode 100644 src/android/JadxJavaProbe.ts create mode 100644 tests/boundary/process/jadxJavaLifecycle.test.ts diff --git a/src/android/JadxConfiguration.ts b/src/android/JadxConfiguration.ts index 696bb4c455..37b20f4e15 100644 --- a/src/android/JadxConfiguration.ts +++ b/src/android/JadxConfiguration.ts @@ -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>, - signal?: AbortSignal, + options: JadxJavaProbeOptions, ): Promise => { 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>, operation: AndroidOperation, - signal?: AbortSignal, + options: JadxJavaProbeOptions, ): Promise => { 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>, - signal?: AbortSignal, + options: JadxJavaProbeOptions, ): Promise => { - 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>, - signal?: AbortSignal, + options: JadxJavaProbeOptions, ): Promise => { - 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>; - 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 = ( diff --git a/src/android/JadxDoctor.ts b/src/android/JadxDoctor.ts index 514ee2e4aa..bd8e6fb894 100644 --- a/src/android/JadxDoctor.ts +++ b/src/android/JadxDoctor.ts @@ -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>, + resourceOwner: FileCloseResourceOwner, + signal?: AbortSignal, ): Promise => { 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 = diff --git a/src/android/JadxJavaProbe.ts b/src/android/JadxJavaProbe.ts new file mode 100644 index 0000000000..adb52d9b5a --- /dev/null +++ b/src/android/JadxJavaProbe.ts @@ -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[2] + >["launcher"]; + readonly timeoutMs?: number; +} + +export interface JadxJavaProbeRefusal { + readonly code: + | "runtime_missing" + | "runtime_unavailable" + | "version_unresolved" + | "unsupported_version"; + readonly message: string; + readonly diagnostics: Readonly>; + 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 => + 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>, + options: JadxJavaProbeOptions, +): Promise> => { + const operation = options.operation ?? "inspect_jadx_configuration"; + try { + return await options.resourceOwner.run(async () => { + let observed: Result | 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 + | 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 => { + 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 }); +}; diff --git a/src/android/JadxProvider.ts b/src/android/JadxProvider.ts index bf68766222..d762edfe9d 100644 --- a/src/android/JadxProvider.ts +++ b/src/android/JadxProvider.ts @@ -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>> + >(); + 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 | undefined; constructor( readonly environment: Readonly>, @@ -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 = diff --git a/src/doctorRuntime.ts b/src/doctorRuntime.ts index 37e16b61dc..78a34beb71 100644 --- a/src/doctorRuntime.ts +++ b/src/doctorRuntime.ts @@ -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, }), diff --git a/tests/boundary/android/jadxConfiguration.test.ts b/tests/boundary/android/jadxConfiguration.test.ts index 34bfebe06f..a7619730dd 100644 --- a/tests/boundary/android/jadxConfiguration.test.ts +++ b/tests/boundary/android/jadxConfiguration.test.ts @@ -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[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); + }, +); diff --git a/tests/boundary/android/jadxDoctor.test.ts b/tests/boundary/android/jadxDoctor.test.ts index a4806f9f21..f5d09866be 100644 --- a/tests/boundary/android/jadxDoctor.test.ts +++ b/tests/boundary/android/jadxDoctor.test.ts @@ -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[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 () => { diff --git a/tests/boundary/process/jadxJavaLifecycle.test.ts b/tests/boundary/process/jadxJavaLifecycle.test.ts new file mode 100644 index 0000000000..7e4608800f --- /dev/null +++ b/tests/boundary/process/jadxJavaLifecycle.test.ts @@ -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>; +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((resolve) => { + started = resolve; + }); + let release: () => void = () => {}; + const held = new Promise((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 | undefined; + try { + await closingArchive; + close = f.provider.close().then(() => { + completed = true; + }); + await new Promise((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(); + } +});