mirror of
https://github.com/morluto/rea.git
synced 2026-10-19 04:19:54 -05:00
fix(android): finalize startup diagnostics before reporting failure
Record the first transport failure before SDK teardown, then capture terminal stderr after owned retirement. Preserve exact opaque cancellation reasons and keep foreign cleanup ownership separate through retries. Validation: 119 focused tests across eight files; five selected baseline regressions; check:fast, docs:check, test discovery; nine real CLI and nine real MCP tiny-APK inspections. Native Windows/Linux startup failures and actual JVM OOM remain unverified. Closes #4844
This commit is contained in:
1 parent
3ddb2e4cf7
commit
3ad1032b11
10 files changed
+912
-151
No files matched your search
@@ -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<Record<string, string | undefined>>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<JadxConfiguration> => {
|
||||
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<Record<string, string | undefined>>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> => {
|
||||
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('"')
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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<SpawnedOwnedProviderProcess>;
|
||||
|
||||
/** 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<void> | undefined;
|
||||
#starting: Promise<void> | undefined;
|
||||
#failure: string | null = null;
|
||||
#failure: JadxTransportFailure | undefined;
|
||||
|
||||
constructor(
|
||||
readonly options: Omit<OwnedProviderProcessSpawnOptions, "runId" | "stdin">,
|
||||
@@ -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<void>((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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
+35
-123
@@ -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<void> =>
|
||||
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<Result<void, AnalysisError>> => {
|
||||
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<Outcome> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Record<string, JsonValue>>,
|
||||
): Promise<string> => {
|
||||
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 },
|
||||
|
||||
@@ -26,7 +26,9 @@ export class AndroidAnalysisService {
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
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 }));
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
+102
@@ -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<void>((resolve) => {
|
||||
requested = resolve;
|
||||
});
|
||||
let cleaning: () => void = () => {};
|
||||
const cleanup = new Promise<void>((resolve) => {
|
||||
cleaning = resolve;
|
||||
});
|
||||
let released: () => void = () => {};
|
||||
const release = new Promise<void>((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,
|
||||
};
|
||||
};
|
||||
Reference in new issue
Block a user