diff --git a/src/android/JadxConfiguration.ts b/src/android/JadxConfiguration.ts index fe517cb704..696bb4c455 100644 --- a/src/android/JadxConfiguration.ts +++ b/src/android/JadxConfiguration.ts @@ -1,3 +1,4 @@ +import { 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"; @@ -248,12 +249,12 @@ const readJadxConfigurationInputs = async ( environment: Readonly>, signal?: AbortSignal, ): Promise => { - signal?.throwIfAborted(); + throwIfJadxInterrupted(signal); requireSupportedHost(); const jar = await resolveJar(environment); const java = await resolveJava(environment, signal); await requireJavaHomeExecutable(environment.JAVA_HOME, java); - signal?.throwIfAborted(); + throwIfJadxInterrupted(signal); return { jar, java, jvmArguments: resolveJvmArguments(environment) }; }; @@ -262,7 +263,7 @@ const validateJadxConfigurationContents = async ( environment: Readonly>, signal?: AbortSignal, ): Promise => { - signal?.throwIfAborted(); + throwIfJadxInterrupted(signal); await inspectJavaRuntime(configuration.java, environment, signal); await requireEngineArchive(configuration.jar, signal); }; @@ -435,7 +436,7 @@ const resolveJava = async ( ([key]) => key.toLowerCase() === "path", )?.[1] ?? ""; for (const directory of [process.cwd(), ...path.split(delimiter)]) { - signal?.throwIfAborted(); + throwIfJadxInterrupted(signal); // Windows PATH commonly quotes individual entries containing spaces. const selected = directory.startsWith('"') && directory.endsWith('"') diff --git a/src/android/JadxFailure.ts b/src/android/JadxFailure.ts new file mode 100644 index 0000000000..6fc1c4aa45 --- /dev/null +++ b/src/android/JadxFailure.ts @@ -0,0 +1,216 @@ +import { JadxInterruption } from "./JadxInterruption.js"; +import { z } from "zod"; +import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { + AnalysisCancelledError, + AnalysisCapabilityUnavailableError, + AnalysisOutputError, + AnalysisTimeoutError, +} from "../domain/analysisErrorCore.js"; +import { ProviderAdapterError } from "../domain/providerAdapterError.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; +import type { AndroidRequest } from "../domain/android/androidAnalysis.js"; +import { + stableFileHashCause, + StableFileHashFailure, +} from "../filesystem/StableFileHash.js"; +import { + RegularFileCleanupFailure, + regularFileCancellation, +} from "../filesystem/OwnedRegularFile.js"; +import type { JadxSession } from "./JadxSession.js"; +import type { JadxMcpTransport } from "./JadxMcpTransport.js"; + +interface FailureContext { + readonly cause: unknown; + readonly request: AndroidRequest; + readonly timeout: AbortSignal; + readonly timeoutMs: number; + readonly signal: AbortSignal | undefined; + readonly session: JadxSession | undefined; +} + +type DiagnosticFailure = { + readonly operation: string; + readonly cause: unknown; + readonly transport: JadxMcpTransport | undefined; +} & ( + | { readonly kind: "startup"; readonly command: string } + | { readonly kind: "execution" } + | { readonly kind: "output"; readonly reason: string } + | { readonly kind: "cancelled" } + | { readonly kind: "timeout"; readonly timeoutMs: number } +); + +/** Freeze failure selection now; read diagnostics once after owned finalization. */ +export const captureJadxFailure = ( + context: FailureContext, +): (() => AnalysisError) => { + const observed = observeJadxFailure(context); + const partial = context.session?.partialObservation(); + let projected: AnalysisError | undefined; + return () => { + if (projected === undefined) { + projected = + observed instanceof AnalysisError + ? observed + : presentDiagnosticFailure(observed); + if (partial !== undefined) projected.retainPartialObservation(partial); + } + return projected; + }; +}; + +const observeJadxFailure = ( + context: FailureContext, +): AnalysisError | DiagnosticFailure => { + const { cause, request, timeout, timeoutMs, signal, session } = context; + const transport = session?.transport; + const observed = transport?.observedFailure(); + const diagnostic = { + operation: request.operation, + transport, + cause, + }; + const physicalFailure = (failureCause: unknown): DiagnosticFailure => + session !== undefined && !session.initialized() + ? { + ...diagnostic, + cause: failureCause, + kind: "startup", + command: session.transport.options.command, + } + : { ...diagnostic, cause: failureCause, kind: "execution" }; + const interruptionFailure = (reason: unknown): DiagnosticFailure => { + if (timeout.aborted && reason === timeout.reason) + return { + ...diagnostic, + cause: reason, + kind: "timeout", + timeoutMs, + }; + if (signal?.aborted === true && reason === signal.reason) + return { ...diagnostic, cause: reason, kind: "cancelled" }; + return physicalFailure(reason); + }; + // Caller reasons are opaque even when they are instances of our own errors. + // Only the owning interruption boundary can select this path; a recorded + // physical failure keeps precedence over a later abort using the same object. + if (observed?.kind === "interrupted" && cause === observed.reason) + return interruptionFailure(observed.reason); + if (cause instanceof JadxInterruption && observed === undefined) + return interruptionFailure(cause.reason); + const cancellation = regularFileCancellation(cause); + const cancellationError = + cancellation === undefined + ? undefined + : timeout.aborted && cancellation.reason === timeout.reason + ? new AnalysisTimeoutError(request.operation, timeoutMs, { + cause: cancellation.reason, + }) + : new AnalysisCancelledError(request.operation, { + cause: cancellation.reason, + }); + if (cause instanceof RegularFileCleanupFailure) { + const primaryCause = + cause.outcome.kind === "failed" + ? stableFileHashCause(cause.outcome.cause) + : cause; + const primary = + cancellationError ?? + (primaryCause instanceof AnalysisError + ? primaryCause + : new ProviderAdapterError("jadx", request.operation, { + cause: primaryCause, + userMessage: + primaryCause instanceof Error + ? primaryCause.message + : String(primaryCause), + })); + const cleanup = new ProviderCleanupError( + "jadx", + [cause.path], + { + reason: + cause.cleanupCause instanceof Error + ? cause.cleanupCause.message + : String(cause.cleanupCause), + }, + { operation: request.operation, cause }, + ); + return analysisErrorWithCleanupFailure(primary, cleanup, request.operation); + } + if (cancellationError !== undefined) return cancellationError; + if (cause instanceof StableFileHashFailure) + return new ProviderAdapterError("jadx", request.operation, { + cause: cause.cause, + userMessage: cause.message, + }); + if (cause instanceof AnalysisError) return cause; + if (cause instanceof z.ZodError) + return { + ...diagnostic, + kind: "output", + reason: "JADX response violated the pinned upstream protocol", + }; + if (observed?.kind === "protocol") + return { + ...diagnostic, + cause: observed.cause, + kind: "output", + reason: observed.cause.message, + }; + if (observed?.kind === "process") return physicalFailure(observed.cause); + if (observed?.kind === "exit") return physicalFailure(cause); + if (observed?.kind === "interrupted") + return interruptionFailure(observed.reason); + if (signal?.aborted === true) + return { ...diagnostic, cause: signal.reason, kind: "cancelled" }; + if (timeout.aborted) + return { ...diagnostic, cause: timeout.reason, kind: "timeout", timeoutMs }; + return physicalFailure( + cause instanceof JadxInterruption ? cause.reason : cause, + ); +}; + +const presentDiagnosticFailure = ( + failure: DiagnosticFailure, +): AnalysisError => { + const output = failure.transport?.diagnostics(); + const options = { + cause: failure.cause, + ...(output === undefined ? {} : { capturedOutput: output }), + }; + if (failure.kind === "cancelled") + return new AnalysisCancelledError(failure.operation, options); + if (failure.kind === "timeout") + return new AnalysisTimeoutError( + failure.operation, + failure.timeoutMs, + options, + ); + if (failure.kind === "output") + return new AnalysisOutputError(failure.operation, failure.reason, options); + if (failure.kind === "startup") { + const reason = `Java could not start REA's Android bridge using ${failure.command}: ${failure.cause instanceof Error ? failure.cause.message : String(failure.cause)}. ${output === undefined || output.stderr.length === 0 ? "No launcher stderr was observed." : "Review the retained launcher diagnostics."}${output?.truncated === true ? " Diagnostics are incomplete." : ""} Review the selected Java/JADX configuration; for a JVM memory limit, review REA_JADX_HEAP_MIB and inherited JVM options such as JAVA_TOOL_OPTIONS.`; + return new AnalysisCapabilityUnavailableError( + "jadx", + failure.operation, + reason, + { + ...options, + userMessage: reason, + }, + ); + } + return new ProviderAdapterError("jadx", failure.operation, { + ...options, + diagnostics: { + reason: + failure.cause instanceof Error + ? failure.cause.message + : String(failure.cause), + }, + }); +}; diff --git a/src/android/JadxInterruption.ts b/src/android/JadxInterruption.ts new file mode 100644 index 0000000000..9e8b2a18dc --- /dev/null +++ b/src/android/JadxInterruption.ts @@ -0,0 +1,11 @@ +/** An Android checkpoint observed interruption; the caller's reason stays opaque. */ +export class JadxInterruption extends Error { + constructor(readonly reason: unknown) { + super("Android operation interrupted", { cause: reason }); + this.name = "AbortError"; + } +} + +export const throwIfJadxInterrupted = (signal?: AbortSignal): void => { + if (signal?.aborted === true) throw new JadxInterruption(signal.reason); +}; diff --git a/src/android/JadxMcpTransport.ts b/src/android/JadxMcpTransport.ts index a40d517f55..7fa886ec42 100644 --- a/src/android/JadxMcpTransport.ts +++ b/src/android/JadxMcpTransport.ts @@ -10,6 +10,7 @@ import type { OwnedProviderProcessSpawnOptions, SpawnedOwnedProviderProcess, } from "../process/ProviderProcess.js"; +import type { AnalysisCapturedOutput } from "../domain/analysisErrorBase.js"; import { ProviderProcessSupervisor, spawnOwnedProviderProcess, @@ -25,6 +26,13 @@ export type JadxLauncher = ( options: OwnedProviderProcessSpawnOptions, ) => Promise; +/** The first observed failure, recorded before notifying the SDK or beginning cleanup. */ +export type JadxTransportFailure = + | { readonly kind: "process"; readonly cause: unknown } + | { readonly kind: "exit" } + | { readonly kind: "protocol"; readonly cause: Error } + | { readonly kind: "interrupted"; readonly reason: unknown }; + /** SDK-framed stdio with REA-owned process cleanup rather than an unowned child. */ export class JadxMcpTransport implements Transport { onclose: Transport["onclose"]; @@ -40,7 +48,7 @@ export class JadxMcpTransport implements Transport { #closed = false; #closePromise: Promise | undefined; #starting: Promise | undefined; - #failure: string | null = null; + #failure: JadxTransportFailure | undefined; constructor( readonly options: Omit, @@ -52,7 +60,10 @@ export class JadxMcpTransport implements Transport { if (this.#started || this.#closed) throw new Error("JADX transport cannot be restarted"); this.#started = true; - this.#starting = this.#acquire(); + this.#starting = this.#acquire().catch((cause: unknown) => { + this.#failure ??= { kind: "process", cause }; + throw cause; + }); return this.#starting; } @@ -80,6 +91,7 @@ export class JadxMcpTransport implements Transport { ) throw new Error("JADX launcher did not supply protocol stdin/stdout"); spawned.process.stdout.on("data", this.#read); + spawned.process.stdout.on("error", this.#error); spawned.process.stderr?.on("data", this.#countStderr); spawned.process.on("error", this.#error); spawned.process.once("exit", this.#exit); @@ -93,9 +105,18 @@ export class JadxMcpTransport implements Transport { if (this.#closed || input === undefined || input === null) throw new Error("JADX protocol process is not writable"); await new Promise((resolve, reject) => { - input.write(serializeMessage(message), (cause) => - cause === null || cause === undefined ? resolve() : reject(cause), - ); + try { + input.write(serializeMessage(message), (cause) => { + if (cause === null || cause === undefined) resolve(); + else { + this.#failure ??= { kind: "process", cause }; + reject(cause); + } + }); + } catch (cause) { + this.#failure ??= { kind: "process", cause }; + reject(cause); + } }); } @@ -108,18 +129,32 @@ export class JadxMcpTransport implements Transport { return this.#closePromise; } - /** Captured provider stderr without persisting inherited environment values. */ - diagnostics(): string { - return this.#supervisor?.snapshot().stderr.text ?? ""; + /** Keep the interruption reason distinct from errors produced by its teardown. */ + interrupt(reason: unknown): Promise { + this.#failure ??= { kind: "interrupted", reason }; + return this.close(); } - /** Whether the retained startup/operation diagnostics lost bytes. */ - diagnosticsTruncated(): boolean { - return this.#supervisor?.snapshot().diagnosticTruncated ?? false; + /** Captured provider stderr without persisting inherited environment values. */ + diagnostics(): AnalysisCapturedOutput | undefined { + const owner = this.#supervisor; + if (owner === undefined) return undefined; + const snapshot = owner.snapshot(); + return { + stdout: "", + stderr: snapshot.stderr.text, + stderr_bytes: snapshot.stderr.observedBytes, + truncated: + snapshot.diagnosticTruncated === true || !owner.observedOutputClose(), + ...(snapshot.exitCode === undefined + ? {} + : { exit_code: snapshot.exitCode }), + ...(snapshot.signal === undefined ? {} : { signal: snapshot.signal }), + }; } /** Preserve the failed protocol/resource constraint after the SDK closes requests. */ - failureReason(): string | null { + observedFailure(): JadxTransportFailure | undefined { return this.#failure; } @@ -137,6 +172,7 @@ export class JadxMcpTransport implements Transport { await this.#starting?.catch(() => undefined); const process = this.#spawned?.process; process?.stdout?.off("data", this.#read); + process?.stdout?.off("error", this.#error); process?.stderr?.off("data", this.#countStderr); process?.off("error", this.#error); process?.off("exit", this.#exit); @@ -157,14 +193,20 @@ export class JadxMcpTransport implements Transport { if (stopped?.status === "incomplete") throw new ProviderCleanupError("jadx", [this.runId], { reason: stopped.reason, - stderr: this.diagnostics(), + stderr: this.diagnostics()?.stderr ?? "", }); } readonly #error = (error: Error): void => { + this.#failure ??= { kind: "process", cause: error }; this.onerror?.(error); + // Reject pending SDK work after joining the same owned teardown on every path. + void this.close().catch((cause: unknown) => + this.onerror?.(cause instanceof Error ? cause : new Error(String(cause))), + ); }; readonly #exit = (): void => { + this.#failure ??= { kind: "exit" }; this.onclose?.(); }; readonly #countStderr = (chunk: Buffer | string): void => { @@ -227,12 +269,7 @@ export class JadxMcpTransport implements Transport { this.#frameLength = frameBytes; } #fail(error: Error): void { - this.#failure ??= error.message; - this.onerror?.(error); - // Closing immediately rejects the SDK's outstanding request. The owner - // awaits close again and retains a cleanup failure in the returned Result. - void this.close().catch((cause: unknown) => - this.onerror?.(cause instanceof Error ? cause : new Error(String(cause))), - ); + this.#failure ??= { kind: "protocol", cause: error }; + this.#error(error); } } diff --git a/src/android/JadxProvider.ts b/src/android/JadxProvider.ts index 67ca008c27..bf68766222 100644 --- a/src/android/JadxProvider.ts +++ b/src/android/JadxProvider.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { throwIfJadxInterrupted } from "./JadxInterruption.js"; import { chmod, copyFile } from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -8,11 +8,8 @@ import { AnalysisError } from "../domain/analysisErrorBase.js"; import { AnalysisCancelledError, AnalysisCapabilityUnavailableError, - AnalysisOutputError, AnalysisInputError, - AnalysisTimeoutError, } from "../domain/analysisErrorCore.js"; -import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import type { AndroidRequest } from "../domain/android/androidAnalysis.js"; @@ -29,14 +26,10 @@ import { import { hashStableFile, stableFileHashCause, - StableFileHashFailure, } from "../filesystem/StableFileHash.js"; import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; import { RegularFileChangedError } from "../filesystem/RegularFile.js"; -import { - RegularFileCleanupFailure, - regularFileCancellation, -} from "../filesystem/OwnedRegularFile.js"; +import { RegularFileCleanupFailure } from "../filesystem/OwnedRegularFile.js"; import { inspectJadxAvailability, resolveJadxConfigurationInputs, @@ -46,6 +39,7 @@ import { import type { JadxLauncher } from "./JadxMcpTransport.js"; import { JadxSession } from "./JadxSession.js"; +import { captureJadxFailure } from "./JadxFailure.js"; const BRIDGE_SOURCE = fileURLToPath( new URL("../../bridge/android/ReaJadxBridge.java", import.meta.url), @@ -58,7 +52,12 @@ const waitForTurn = ( signal?: AbortSignal, ): Promise => new Promise((resolve, reject) => { - const abort = () => reject(new AnalysisCancelledError(request.operation)); + const abort = () => + reject( + new AnalysisCancelledError(request.operation, { + cause: signal?.reason, + }), + ); signal?.addEventListener("abort", abort, { once: true }); if (signal?.aborted === true) abort(); void predecessor.then(() => { @@ -67,104 +66,6 @@ const waitForTurn = ( }); }); -const executionError = (context: { - cause: unknown; - request: AndroidRequest; - timeout: AbortSignal; - timeoutMs: number; - signal: AbortSignal | undefined; - session: JadxSession | undefined; -}): AnalysisError => { - const { cause, request, timeout, timeoutMs, signal, session } = context; - const cancellation = regularFileCancellation(cause); - const cancellationError = - cancellation === undefined - ? undefined - : timeout.aborted && cancellation.reason === timeout.reason - ? new AnalysisTimeoutError(request.operation, timeoutMs, { - cause: cancellation.reason, - }) - : new AnalysisCancelledError(request.operation, { - cause: cancellation.reason, - }); - if (cause instanceof RegularFileCleanupFailure) { - const primaryCause = - cause.outcome.kind === "failed" - ? stableFileHashCause(cause.outcome.cause) - : cause; - const primary = - cancellationError ?? - (primaryCause instanceof AnalysisError - ? primaryCause - : new ProviderAdapterError("jadx", request.operation, { - cause: primaryCause, - userMessage: - primaryCause instanceof Error - ? primaryCause.message - : String(primaryCause), - })); - const cleanup = new ProviderCleanupError( - "jadx", - [cause.path], - { - reason: - cause.cleanupCause instanceof Error - ? cause.cleanupCause.message - : String(cause.cleanupCause), - }, - { operation: request.operation, cause }, - ); - return analysisErrorWithCleanupFailure(primary, cleanup, request.operation); - } - if (cancellationError !== undefined) return cancellationError; - if (cause instanceof StableFileHashFailure) - return new ProviderAdapterError("jadx", request.operation, { - cause: cause.cause, - userMessage: cause.message, - }); - if (cause instanceof AnalysisError) return cause; - if (signal?.aborted === true) - return new AnalysisCancelledError(request.operation, { cause }); - if (timeout.aborted) - return new AnalysisTimeoutError(request.operation, timeoutMs); - const protocolFailure = session?.transport.failureReason(); - if (protocolFailure !== undefined && protocolFailure !== null) - return new AnalysisOutputError(request.operation, protocolFailure, { - cause, - }); - if (cause instanceof z.ZodError) - return new AnalysisOutputError( - request.operation, - "JADX response violated the pinned upstream protocol", - { cause }, - ); - if (session !== undefined && !session.initialized()) { - const stderr = session.transport.diagnostics(); - const reason = `Java could not start REA's Android bridge using ${session.transport.options.command}: ${cause instanceof Error ? cause.message : String(cause)}.${stderr.length === 0 ? "" : ` Java diagnostics: ${stderr}`} Select a full JDK including jdk.compiler via JAVA_HOME, or a working java on PATH; verify it with java --list-modules. A JRE cannot compile REA's source bridge. If the JDK works, verify the REA bridge and configured JADX JAR installation.`; - return new AnalysisCapabilityUnavailableError( - "jadx", - request.operation, - reason, - { - cause, - userMessage: reason, - capturedOutput: { - stdout: "", - stderr, - truncated: session.transport.diagnosticsTruncated(), - }, - }, - ); - } - return new ProviderAdapterError("jadx", request.operation, { - cause, - diagnostics: { - reason: cause instanceof Error ? cause.message : String(cause), - stderr: session?.transport.diagnostics() ?? "", - }, - }); -}; - const androidTargetChanged = ( operation: string, path: string, @@ -249,7 +150,7 @@ const cleanupFailure = ( const cleanup = async ( resources: PendingCleanup, ): Promise> => { - const { session, root, previousError } = resources; + const { session, root } = resources; let sessionCloseFailed = false; let sessionCloseCause: unknown; try { @@ -269,7 +170,7 @@ const cleanup = async ( resources.operationCause instanceof RegularFileCleanupFailure && resources.operationCause.owner === owner ) - previousError?.resolveCleanup(); + resources.previousError?.resolveCleanup(); resources.fileHandles.delete(owner); } catch (cause: unknown) { fileCloseCauses.push(cause); @@ -359,7 +260,11 @@ export class JadxProvider implements AndroidAnalysisPort { options?: ExecutionOptions, ): Promise { if (this.#shutdown.signal.aborted) - return err(new AnalysisCancelledError(request.operation)); + return err( + new AnalysisCancelledError(request.operation, { + cause: this.#shutdown.signal.reason, + }), + ); const signal = options?.signal === undefined ? this.#shutdown.signal @@ -371,13 +276,21 @@ export class JadxProvider implements AndroidAnalysisPort { }); try { await waitForTurn(predecessor, request, signal); - } catch { + } catch (cause) { void predecessor.then(release); - return err(new AnalysisCancelledError(request.operation)); + return err( + cause instanceof AnalysisError + ? cause + : new AnalysisCancelledError(request.operation, { cause }), + ); } try { if (signal.aborted) - return err(new AnalysisCancelledError(request.operation)); + return err( + new AnalysisCancelledError(request.operation, { + cause: signal.reason, + }), + ); clearTimeout(this.#idle); if (this.#pendingCleanup !== undefined) { const retired = await this.#retire(); @@ -485,7 +398,7 @@ export class JadxProvider implements AndroidAnalysisPort { request.operation, signal, ); - signal.throwIfAborted(); + throwIfJadxInterrupted(signal); const jarHash = await hashAndroidSource( configuration.jar, request.operation, @@ -576,7 +489,7 @@ export class JadxProvider implements AndroidAnalysisPort { BRIDGE_SOURCE, undefined, ); - workspaceSignal.throwIfAborted(); + throwIfJadxInterrupted(workspaceSignal); session = new JadxSession( { command: configuration.java, @@ -619,11 +532,11 @@ export class JadxProvider implements AndroidAnalysisPort { timeoutMs: timeoutMs.value, }), ); - activeSignal.throwIfAborted(); + throwIfJadxInterrupted(activeSignal); this.#retained = retained; return outcome; } catch (cause) { - const previousError = executionError({ + const failure = captureJadxFailure({ cause, request, timeout, @@ -631,9 +544,6 @@ export class JadxProvider implements AndroidAnalysisPort { signal: options?.signal, session, }); - const partialObservation = session?.partialObservation(); - if (partialObservation !== undefined) - previousError.retainPartialObservation(partialObservation); // Admission can fail before selecting an existing session for execution. root ??= this.#retained?.root; session ??= this.#retained?.session; @@ -643,12 +553,14 @@ export class JadxProvider implements AndroidAnalysisPort { session, root, fileHandles, - previousError, + get previousError() { + return failure(); + }, operationCause: cause, reportedErrors: [], }; const retired = await this.#retire(); - return retired.ok ? err(previousError) : retired; + return retired.ok ? err(failure()) : retired; } } } diff --git a/src/android/JadxSession.ts b/src/android/JadxSession.ts index ef7bcbccfe..f99557c9bf 100644 --- a/src/android/JadxSession.ts +++ b/src/android/JadxSession.ts @@ -1,3 +1,4 @@ +import { throwIfJadxInterrupted } from "./JadxInterruption.js"; import { Client } from "@modelcontextprotocol/client"; import { createAnalysisExecution, @@ -86,11 +87,12 @@ export class JadxSession { this.#serverVersion = null; this.#engineVersion = null; const abort = () => { - void this.transport.close().catch(() => undefined); + void this.transport.interrupt(signal.reason).catch(() => undefined); }; signal.addEventListener("abort", abort, { once: true }); try { - signal.throwIfAborted(); + if (signal.aborted) abort(); + throwIfJadxInterrupted(signal); this.transport.beginOperation(); if (!this.#connected) { await this.#client.connect(this.transport, { timeout: 30_000 }); @@ -215,7 +217,7 @@ export class JadxSession { name: string, input: Readonly>, ): Promise => { - signal.throwIfAborted(); + throwIfJadxInterrupted(signal); // Keep an explicit bound: omitting it falls back to the SDK's 60-second default. const response = await this.#client.callTool( { name, arguments: input }, diff --git a/src/application/android/AndroidAnalysisService.ts b/src/application/android/AndroidAnalysisService.ts index d642764811..45c802dc96 100644 --- a/src/application/android/AndroidAnalysisService.ts +++ b/src/application/android/AndroidAnalysisService.ts @@ -26,7 +26,9 @@ export class AndroidAnalysisService { options?: ExecutionOptions, ): Promise> { if (options?.signal?.aborted === true) - return err(new AnalysisCancelledError(operation)); + return err( + new AnalysisCancelledError(operation, { cause: options.signal.reason }), + ); const request = androidRequestSchema.safeParse({ operation, input }); if (!request.success) return err(new AnalysisInputError(operation, { cause: request.error })); diff --git a/src/process/ProviderProcess.ts b/src/process/ProviderProcess.ts index 67ed3a97d7..b85ceacb2c 100644 --- a/src/process/ProviderProcess.ts +++ b/src/process/ProviderProcess.ts @@ -374,6 +374,11 @@ export class ProviderProcessSupervisor { return { code: exit.code, signal: exit.signal }; } + /** Whether the process owner acknowledged output closure, separately from exit. */ + observedOutputClose(): boolean { + return this.#outputClosed; + } + /** Latest retained output and exit observation, with explicit configured truncation. */ snapshot(): ProviderProcessSnapshot { const outputIncomplete = diff --git a/tests/boundary/process/jadxStartup.test.ts b/tests/boundary/process/jadxStartup.test.ts new file mode 100644 index 0000000000..f5008e24e3 --- /dev/null +++ b/tests/boundary/process/jadxStartup.test.ts @@ -0,0 +1,473 @@ +import { RegularFileCleanupFailure } from "../../../src/filesystem/OwnedRegularFile.js"; +import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js"; +import { StableFileHashFailure } from "../../../src/filesystem/StableFileHash.js"; +import { z } from "zod"; +import { AnalysisResourceConstraintError } from "../../../src/domain/analysisErrorCore.js"; +import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; +import { JadxSession } from "../../../src/android/JadxSession.js"; +import { createJadxProtocolFixture } from "../../fixtures/android/jadx.js"; +import { access, open } from "node:fs/promises"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { expect, it as test, vi } from "vitest"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { createJadxStartupFixture as setup } from "../../fixtures/android/jadxStartup.js"; + +const it = test.skipIf(process.platform === "win32"); + +it.each(["exit", "process", "stdout", "protocol"])( + "retains %s failure and final diagnostics when cancellation arrives during cleanup", + async (kind) => { + const f = await setup(); + const controller = new AbortController(); + const outcome = f.service.execute( + "inspect_android_package", + { path: f.apk }, + { signal: controller.signal }, + ); + await f.request; + const physical = new Error("controlled physical startup failure"); + if (kind === "exit") f.process.exit(); + else if (kind === "process") f.process.emit("error", physical); + else if (kind === "stdout") f.process.stdout.emit("error", physical); + else f.process.stdout.write("{malformed\n"); + await f.cleanup; + controller.abort({ later: "caller cancellation" }); + f.release(); + const result = await outcome; + if (result.ok) throw new Error("Expected startup failure"); + expect(result.error).toMatchObject({ + _tag: + kind === "protocol" + ? "AnalysisOutputError" + : "AnalysisCapabilityUnavailableError", + capturedOutput: { + stderr: "late startup diagnostic", + // stdout carries the failed protocol; the retained stderr closes completely. + truncated: false, + exit_code: 17, + }, + }); + if (kind === "process" || kind === "stdout") + expect(result.error.cause).toBe(physical); + expect(result.error.partialObservation).toMatchObject({ + provider_id: "jadx", + operation: "inspect_android_package", + }); + expect(projectAnalysisError(result.error).message).not.toContain( + "Select a full JDK", + ); + await expect(access(f.workspace()!)).rejects.toMatchObject({ + code: "ENOENT", + }); + }, +); + +it("keeps observed exit ahead of an abort before the SDK rejection is consumed", async () => { + const f = await setup(); + const controller = new AbortController(); + const outcome = f.service.execute( + "inspect_android_package", + { path: f.apk }, + { signal: controller.signal }, + ); + await f.request; + f.process.exit(); + controller.abort("after physical exit"); + await f.cleanup; + f.release(); + const result = await outcome; + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCapabilityUnavailableError" }, + }); +}); + +const typedReason = () => + new AnalysisResourceConstraintError( + "unrelated_operation", + "memory", + "caller-owned reason", + null, + ); +const foreignCleanup = () => + new ProviderCleanupError("foreign", ["/foreign/owned-resource"], { + reason: "foreign cleanup pending", + }); + +it.each([ + null, + "caller stopped", + { caller: "exact reason" }, + typedReason(), + foreignCleanup(), + new z.ZodError([]), + new StableFileHashFailure( + "/foreign/source", + new Error("foreign hash failed"), + ), +])( + "preserves first cancellation reason %j through later process errors and final output", + async (reason) => { + const f = await setup(); + const controller = new AbortController(); + const outcome = f.service.execute( + "inspect_android_package", + { path: f.apk }, + { signal: controller.signal }, + ); + await f.request; + controller.abort(reason); + await f.cleanup; + f.process.stdin.emit("error", new Error("later teardown error")); + f.release(); + const result = await outcome; + if (result.ok) throw new Error("Expected cancellation"); + expect(result.error).toMatchObject({ + _tag: "AnalysisCancelledError", + capturedOutput: { stderr: "late startup diagnostic" }, + }); + expect(result.error.cause).toBe(reason); + }, +); + +it("keeps a first deadline and its reason ahead of later caller cancellation", async () => { + const f = await setup(); + const deadline = new AbortController(); + const caller = new AbortController(); + const reason = new Error("controlled first deadline"); + const clock = vi + .spyOn(AbortSignal, "timeout") + .mockReturnValueOnce(deadline.signal); + try { + const outcome = f.service.execute( + "inspect_android_package", + { path: f.apk }, + { signal: caller.signal }, + ); + await f.request; + deadline.abort(reason); + await f.cleanup; + caller.abort("later caller cancellation"); + f.release(); + const result = await outcome; + if (result.ok) throw new Error("Expected timeout"); + expect(result.error).toMatchObject({ + _tag: "AnalysisTimeoutError", + capturedOutput: { stderr: "late startup diagnostic" }, + }); + expect(result.error.cause).toBe(reason); + } finally { + clock.mockRestore(); + } +}); + +it.each([null, "admission stopped", { caller: "admission" }])( + "preserves pre-admission cancellation reason %j", + async (reason) => { + const f = await setup(); + const caller = new AbortController(); + caller.abort(reason); + const result = await f.service.execute( + "inspect_android_package", + { path: f.apk }, + { signal: caller.signal }, + ); + if (result.ok) throw new Error("Expected admission cancellation"); + expect(result.error).toMatchObject({ _tag: "AnalysisCancelledError" }); + expect(result.error.cause).toBe(reason); + expect(f.workspace()).toBeUndefined(); + }, +); + +it("retains an independently queued caller's exact cancellation reason", async () => { + const f = await setup(); + const first = new AbortController(); + const queued = new AbortController(); + const active = f.service.execute( + "inspect_android_package", + { path: f.apk }, + { signal: first.signal }, + ); + await f.request; + const target = await parseBinaryTarget(f.apk); + if (!target.ok) throw target.error; + const waiting = f.provider.execute( + target.value, + { operation: "inspect_android_package", input: { path: f.apk } }, + { signal: queued.signal }, + ); + const reason = { caller: "queued owner" }; + queued.abort(reason); + const result = await waiting; + if (result.ok) throw new Error("Expected queued cancellation"); + expect(result.error.cause).toBe(reason); + first.abort("finish active fixture"); + await f.cleanup; + f.release(); + expect(await active).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); +}); + +it("preserves the frozen physical failure when the operation deadline elapses during cleanup", async () => { + const f = await setup(); + const deadline = new AbortController(); + const clock = vi + .spyOn(AbortSignal, "timeout") + .mockReturnValueOnce(deadline.signal); + try { + const outcome = f.service.execute("inspect_android_package", { + path: f.apk, + }); + await f.request; + f.process.exit(); + await f.cleanup; + deadline.abort(new Error("controlled deadline")); + f.release(); + const result = await outcome; + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCapabilityUnavailableError", + capturedOutput: { stderr: "late startup diagnostic" }, + }, + }); + } finally { + clock.mockRestore(); + } +}); + +it("retains the startup primary across cleanup failure and retry", async () => { + const f = await setup(); + f.failCleanup(true); + const outcome = f.service.execute("inspect_android_package", { path: f.apk }); + await f.request; + f.process.exit(); + await f.cleanup; + f.process.stderr.write("available before failed cleanup"); + f.release(); + const result = await outcome; + if (result.ok) throw new Error("Expected retained cleanup failure"); + const error = result.error; + expect(error.cleanupIncomplete).toBe(true); + expect(error.cleanupPrimary).toMatchObject({ + _tag: "AnalysisCapabilityUnavailableError", + capturedOutput: { + stderr: "available before failed cleanup", + truncated: true, + }, + }); + expect(error.cleanupResources).toContain(f.workspace()); + f.failCleanup(false); + await f.provider.close(); + expect(projectAnalysisError(error)).toMatchObject({ + code: "capability_unavailable", + details: { + captured_output: { + stderr: "available before failed cleanup", + truncated: true, + }, + }, + }); + expect(error.cleanupResources).toEqual([]); +}); + +it("returns terminal startup diagnostics and partial identity through the MCP contract", async () => { + const f = await setup(); + const session = createTestBinarySession(() => { + throw new Error("Unexpected native provider"); + }); + const server = createServer( + { kind: "session", session }, + { androidAnalysis: f.provider }, + ); + const client = new Client({ + name: "jadx-startup-finalization", + version: "1", + }); + try { + const [local, remote] = InMemoryTransport.createLinkedPair(); + await server.connect(remote); + await client.connect(local); + const pending = client.callTool({ + name: "inspect_android_package", + arguments: { path: f.apk }, + }); + await f.request; + f.process.exit(); + await f.cleanup; + f.release(); + const response = await pending; + expect(parseMcpToolError(response).error).toMatchObject({ + code: "capability_unavailable", + details: { + captured_output: { + stderr: "late startup diagnostic", + truncated: false, + exit_code: 17, + }, + partial_observation: { + provider_id: "jadx", + target: { selected_path: f.apk }, + }, + }, + }); + } finally { + f.release(); + await client.close(); + await server.close(); + await session.close(); + } +}); + +it("does not adopt a typed caller reason's foreign cleanup across failed retirement and retry", async () => { + const f = await setup(); + const caller = new AbortController(); + const reason = foreignCleanup(); + f.failCleanup(true); + const outcome = f.service.execute( + "inspect_android_package", + { path: f.apk }, + { signal: caller.signal }, + ); + await f.request; + caller.abort(reason); + await f.cleanup; + f.release(); + const result = await outcome; + if (result.ok) throw new Error("Expected cancellation with retained cleanup"); + expect(result.error.cleanupPrimary).toMatchObject({ + _tag: "AnalysisCancelledError", + cause: reason, + }); + expect(result.error.cleanupResources).not.toContain( + "/foreign/owned-resource", + ); + f.failCleanup(false); + await f.provider.close(); + expect(projectAnalysisError(result.error).code).toBe("cancelled"); + expect(reason.cleanupIncomplete).toBe(true); + expect(reason.cleanupResources).toEqual(["/foreign/owned-resource"]); +}); + +it("preserves an independently recorded typed physical failure sharing the later abort reason", async () => { + const f = await setup(); + const caller = new AbortController(); + const reason = typedReason(); + const outcome = f.service.execute( + "inspect_android_package", + { path: f.apk }, + { signal: caller.signal }, + ); + await f.request; + f.process.emit("error", reason); + caller.abort(reason); + await f.cleanup; + f.release(); + const result = await outcome; + if (result.ok) throw new Error("Expected physical failure"); + expect(result.error).toMatchObject({ + _tag: "AnalysisCapabilityUnavailableError", + }); + expect(result.error.cause).toBe(reason); +}); + +it("preserves typed cancellation at the completed-analysis handoff", async () => { + const f = await createJadxProtocolFixture(); + const caller = new AbortController(); + const reason = typedReason(); + const execute = JadxSession.prototype.execute; + const checkpoint = vi + .spyOn(JadxSession.prototype, "execute") + .mockImplementationOnce(async function (this: JadxSession, context) { + const result = await execute.call(this, context); + caller.abort(reason); + return result; + }); + try { + const result = await f.service.execute( + "inspect_android_package", + { path: f.apk }, + { signal: caller.signal }, + ); + if (result.ok) + throw new Error("Expected cancellation at completion handoff"); + expect(result.error).toMatchObject({ _tag: "AnalysisCancelledError" }); + expect(result.error.cause).toBe(reason); + } finally { + checkpoint.mockRestore(); + } +}); + +it("normalizes a typed reason at the session's already-aborted checkpoint", async () => { + const f = await createJadxProtocolFixture(); + const caller = new AbortController(); + const reason = foreignCleanup(); + const execute = JadxSession.prototype.execute; + const checkpoint = vi + .spyOn(JadxSession.prototype, "execute") + .mockImplementationOnce(async function (this: JadxSession, context) { + caller.abort(reason); + return execute.call(this, context); + }); + try { + const result = await f.service.execute( + "inspect_android_package", + { path: f.apk }, + { signal: caller.signal }, + ); + if (result.ok) + throw new Error("Expected cancellation before session acquisition"); + expect(result.error).toMatchObject({ _tag: "AnalysisCancelledError" }); + expect(result.error.cause).toBe(reason); + expect(result.error.cleanupIncomplete).toBe(false); + expect(f.launches).toEqual([]); + expect(reason.cleanupIncomplete).toBe(true); + expect(reason.cleanupResources).toEqual(["/foreign/owned-resource"]); + } finally { + checkpoint.mockRestore(); + } +}); + +it("keeps a foreign file-cleanup abort reason opaque at the session checkpoint", async () => { + const f = await createJadxProtocolFixture(); + const caller = new AbortController(); + const handle = await open(f.apk, "r"); + const owner = new OwnedFileHandle(handle); + const reason = new RegularFileCleanupFailure( + f.apk, + owner, + { kind: "failed", cause: new Error("foreign read failed") }, + new Error("foreign close failed"), + ); + const execute = JadxSession.prototype.execute; + const checkpoint = vi + .spyOn(JadxSession.prototype, "execute") + .mockImplementationOnce(async function (this: JadxSession, context) { + caller.abort(reason); + return execute.call(this, context); + }); + try { + const result = await f.service.execute( + "inspect_android_package", + { path: f.apk }, + { signal: caller.signal }, + ); + if (result.ok) throw new Error("Expected checkpoint cancellation"); + expect(result.error).toMatchObject({ + _tag: "AnalysisCancelledError", + cleanupIncomplete: false, + }); + expect(result.error.cause).toBe(reason); + // The foreign owner is still open and under this test's authority. + expect((await handle.stat()).isFile()).toBe(true); + } finally { + checkpoint.mockRestore(); + await owner.close(); + } +}); diff --git a/tests/fixtures/android/jadxStartup.ts b/tests/fixtures/android/jadxStartup.ts new file mode 100644 index 0000000000..0b698cf9e4 --- /dev/null +++ b/tests/fixtures/android/jadxStartup.ts @@ -0,0 +1,102 @@ +import { EventEmitter } from "node:events"; +import { PassThrough, Writable } from "node:stream"; +import { onTestFinished } from "vitest"; +import { JadxProvider } from "../../../src/android/JadxProvider.js"; +import { AndroidAnalysisService } from "../../../src/application/android/AndroidAnalysisService.js"; +import type { ProviderProcessHandle } from "../../../src/process/ProviderProcess.js"; +import { createJadxProtocolFixture } from "./jadx.js"; + +/** Simulated process events around the real SDK, provider, admission and workspace owners. */ +class StartupProcess extends EventEmitter implements ProviderProcessHandle { + readonly pid = 1; + readonly stdout = new PassThrough(); + readonly stderr = new PassThrough(); + readonly stdin: Writable; + exitCode: number | null = null; + readonly signalCode = null; + + constructor(requested: () => void) { + super(); + this.stdin = new Writable({ + write: (_chunk, _encoding, complete) => { + complete(); + requested(); + }, + }); + } + + exit(): void { + this.exitCode = 17; + this.emit("exit", 17, null); + } + + kill(): boolean { + throw new Error("Fixture cleanup must use its explicit owner"); + } +} + +export const createJadxStartupFixture = async () => { + const base = await createJadxProtocolFixture(); + let requested: () => void = () => {}; + const request = new Promise((resolve) => { + requested = resolve; + }); + let cleaning: () => void = () => {}; + const cleanup = new Promise((resolve) => { + cleaning = resolve; + }); + let released: () => void = () => {}; + const release = new Promise((resolve) => { + released = resolve; + }); + const process = new StartupProcess(requested); + let workspace: string | undefined; + let fails = false; + const provider = new JadxProvider(base.environment, async (options) => { + workspace = options.cwd; + return { + process, + ownership: { + runId: options.runId, + leaderPid: process.pid, + processGroupId: process.pid, + }, + cleanup: async () => { + cleaning(); + await release; + if (fails) + return { + cleaned: false, + reason: "controlled startup cleanup unavailable", + }; + process.stderr.end("late startup diagnostic"); + process.stdout.end(); + process.stdin.end(); + if (process.exitCode === null) process.exit(); + process.emit("close", process.exitCode, null); + return { cleaned: true, signaled: false }; + }, + }; + }); + onTestFinished(async () => { + fails = false; + released(); + await provider.close(); + process.stdin.destroy(); + process.stdout.destroy(); + process.stderr.destroy(); + }); + return { + apk: base.apk, + provider, + process, + request, + cleanup, + service: new AndroidAnalysisService(provider), + release: released, + failCleanup: (value: boolean) => { + fails = value; + }, + workspace: () => workspace, + }; +};