fix(apktool): retain command cleanup ownership through recovery

Admit provider operations through one command resource scope, retain exact process and preparation-helper cleanup capabilities, and bind original failures to their live cleanup leases. Keep unknown recovery capabilities unresolved and fence their workspaces.

Interpret version output at command completion, preserve opaque cancellation and compiler output, and attribute prerequisite failures to the consuming operation. Prepare Darwin ownership outside the short version deadline.

Validation: 106 focused tests across 11 files; final 44 source regressions; build, typecheck/lint, docs and test discovery. Eight new controls fail on the clean baseline. Tiny authored launchers exercise process cleanup and public CLI/stdio MCP; actual Apktool/JVM and native Windows remain unverified. Heavy stress/profiling remained stopped.

Closes #4808
This commit is contained in:
morluto committed 2026-10-17 22:22:47 +08:00
1 parent 376eeca770
commit 11bcf44252
8 files changed
+940 -284

No files matched your search

+188
View File
@@ -0,0 +1,188 @@
import {
AnalysisCleanupLease,
AnalysisCleanupLeaseGroup,
} from "../artifacts/AnalysisCleanupLease.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js";
import { OwnedCommandFailure } from "../process/OwnedCommand.js";
interface CommandOwner {
readonly lease: AnalysisCleanupLease;
readonly cleanup: AnalysisError;
readonly root: string | undefined;
}
/** Retain exact command/helper capabilities across invocation and shutdown. */
export class ApktoolCommandCleanup {
readonly #scope = new ArtifactResourceScope();
readonly #owners = new Set<CommandOwner>();
async run<T>(operation: () => Promise<T>, name: string): Promise<T> {
try {
return await this.#scope.run(() => {
this.#prune();
return operation();
});
} catch (cause) {
throw this.#retryFailure(cause, name);
}
}
async close(): Promise<void> {
try {
await this.#scope.close();
} catch (cause) {
throw this.#retryFailure(cause, "close");
} finally {
this.#prune();
}
}
pendingFor(root: string): boolean {
this.#prune();
return [...this.#owners].some((owner) => owner.root === root);
}
retainFailure(
cause: unknown,
primary: AnalysisError,
operation: string,
root?: string,
): AnalysisError {
const retained: CommandOwner[] = [];
const command = cause instanceof OwnedCommandFailure ? cause : undefined;
const preparation =
command?.preparationFailure ??
(cause instanceof DarwinProcessOwnershipInspectionError
? cause
: undefined);
if (preparation?.cleanupFailure !== undefined) {
const cleanup = preparation.cleanupFailure;
retained.push(
this.#retain(
cleanup,
async () => {
if (!cleanup.cleanupIncomplete) return;
if (preparation.cleanupOwner === undefined) throw cleanup;
await preparation.cleanupOwner.close();
if (cleanup.cleanupIncomplete) throw cleanup;
},
root,
),
);
}
if (
command !== undefined &&
(command.cleanupOwner !== undefined || command.cleanupFailure !== null)
) {
const owner = command.cleanupOwner;
const cleanup = new ProviderCleanupError(
"apktool",
command.resources,
{
reason:
command.cleanupFailure ?? "Command cleanup remains incomplete",
},
{ operation, cause },
);
const retainedOwner = this.#retain(
cleanup,
async () => {
if (owner === undefined) throw cleanup;
const result = await owner.stop();
if (result.status === "incomplete") throw new Error(result.reason);
},
root,
);
retainedOwner.lease.bindCleanupError(cleanup);
retained.push(retainedOwner);
}
if (retained.length === 0) return primary;
const cleanup = new ProviderCleanupError(
"apktool",
retained.flatMap((owner) => owner.cleanup.cleanupResources),
{},
{ operation, cause },
);
this.#bind(cleanup, retained);
return analysisErrorWithCleanupFailure(
primary,
cleanup,
operation,
).bindCleanupDependencies([primary, cleanup]);
}
#retain(
cleanup: AnalysisError,
release: () => Promise<void>,
root: string | undefined,
): CommandOwner {
const lease = new AnalysisCleanupLease(async () => {
try {
await release();
} catch (cause) {
throw new ArtifactReaderFailure("unavailable", cleanup.message, {
cause,
cleanup: {
reason: cleanup.message,
resources: cleanup.cleanupResources,
},
});
}
}, cleanup.cleanupResources);
const owner = { lease, cleanup, root };
this.#owners.add(owner);
this.#scope.retain({
kind: "lease",
resource: cleanup.cleanupResources[0] ?? "apktool command",
lease,
});
return owner;
}
#bind(error: AnalysisError, owners: readonly CommandOwner[]): void {
new AnalysisCleanupLeaseGroup(
error,
owners.map((owner) => owner.lease),
);
// Helper cleanup can itself contain independently released peers. Its
// producer owns current labels; the leases own completion of this retry set.
error.bindCleanupResourceOwner({
get cleanupResources() {
return [
...new Set(
owners
.filter((owner) => !owner.lease.released)
.flatMap((owner) => owner.cleanup.cleanupResources),
),
];
},
});
}
#retryFailure(cause: unknown, operation: string): unknown {
if (
!(cause instanceof ArtifactReaderFailure) ||
cause.cleanup === undefined
)
return cause;
const owners = [...this.#owners].filter((owner) => !owner.lease.released);
const failure = new ProviderCleanupError(
"apktool",
owners.flatMap((owner) => owner.cleanup.cleanupResources),
{ reason: cause.cleanup.reason },
{ operation, cause },
);
this.#bind(failure, owners);
return failure;
}
#prune(): void {
for (const owner of this.#owners)
if (owner.lease.released) this.#owners.delete(owner);
}
}
+16
View File
@@ -0,0 +1,16 @@
import type { AnalysisCapturedOutput } from "../domain/analysisErrorBase.js";
import type { ProviderProcessSnapshot } from "../process/ProviderProcess.js";
/** Project the command owner's bounded output without losing examined byte counts. */
export const capturedApktoolCommandOutput = (
output: ProviderProcessSnapshot,
): AnalysisCapturedOutput => ({
stdout: output.stdout.text,
stderr: output.stderr.text,
truncated:
output.diagnosticTruncated === true || output.outputIncomplete === true,
stdout_bytes: output.stdout.observedBytes,
stderr_bytes: output.stderr.observedBytes,
exit_code: output.exitCode ?? null,
signal: output.signal ?? null,
});
+378
View File
@@ -0,0 +1,378 @@
import { afterEach, expect, it, vi } from "vitest";
import { createDeferred } from "../../tests/fixtures/binarySession.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js";
import {
OwnedCommandFailure,
runOwnedCommand,
prepareOwnedCommand,
} from "../process/OwnedCommand.js";
import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js";
import { ApktoolProvider } from "./ApktoolProvider.js";
vi.mock("../process/OwnedCommand.js", async (original) => ({
...(await original<typeof import("../process/OwnedCommand.js")>()),
runOwnedCommand: vi.fn(),
prepareOwnedCommand: vi.fn(async () => undefined),
}));
afterEach(() => {
vi.mocked(runOwnedCommand).mockReset();
vi.mocked(prepareOwnedCommand).mockReset().mockResolvedValue(undefined);
vi.useRealTimers();
});
const provider = () =>
new ApktoolProvider({
environment: { REA_APKTOOL_COMMAND: "/authored/apktool" },
});
const version = {
stdout: { text: "2.9.3\n" },
stderr: { text: "" },
} as Awaited<ReturnType<typeof runOwnedCommand>>;
const inspect = (client: ApktoolProvider, signal?: AbortSignal) =>
client.execute(
{ operation: "inspect_apktool_client", input: {} },
signal === undefined ? {} : { signal },
);
it.each(["process", "helper"] as const)(
"retains %s cleanup, fences replacement acquisition and clears original and retry errors",
async (kind) => {
const client = provider();
let denied = true;
const reason = { caller: "authored cancellation" };
const controller = new AbortController();
const helperCleanup = new ProviderCleanupError(
"process-ownership",
["authored-helper-root"],
{},
);
const stop = vi.fn(async () =>
denied
? { status: "incomplete" as const, reason: "authored stop denial" }
: { status: "verified-cleanup" as const },
);
const helperClose = vi.fn(async () => {
if (denied) throw helperCleanup;
helperCleanup.resolveCleanup();
});
const compilerOutput = {
stdout: "authored compiler stdout",
stderr: "authored compiler stderr",
truncated: true,
stdout_bytes: 99,
stderr_bytes: 105,
exit_code: null,
signal: "SIGTERM",
};
const preparation = new DarwinProcessOwnershipInspectionError(
"helper cancellation",
{
cancellation: { reason },
capturedOutput: compilerOutput,
cleanupFailure: helperCleanup,
cleanupOwner: { close: helperClose },
},
);
vi.mocked(runOwnedCommand)
.mockImplementationOnce(async () => {
controller.abort(reason);
throw new OwnedCommandFailure(
"cancelled",
"authored command cancellation",
null,
kind === "process" ? "authored stop denial" : null,
{
cancellation: { reason },
...(kind === "helper" ? { preparationFailure: preparation } : {}),
},
kind === "process" ? ["authored-process-family"] : [],
kind === "process"
? ({ stop } as unknown as ProviderProcessSupervisor)
: undefined,
);
})
.mockImplementation(async (_spawn, limits) => {
limits.observeCompleted?.(version);
return version;
});
try {
const first = await inspect(client, controller.signal);
if (first.ok) throw new Error("Expected retained cleanup");
expect(first.error.cleanupIncomplete).toBe(true);
expect(first.error.cleanupPrimary?.cause).toBe(reason);
if (kind === "helper")
expect(first.error.capturedOutput).toEqual(compilerOutput);
const blocked = await inspect(client);
if (blocked.ok) throw new Error("Expected cleanup admission refusal");
expect(blocked.error.cleanupIncomplete).toBe(true);
expect(runOwnedCommand).toHaveBeenCalledOnce();
denied = false;
expect((await inspect(client)).ok).toBe(true);
expect(first.error.cleanupIncomplete).toBe(false);
expect(blocked.error.cleanupIncomplete).toBe(false);
expect(first.error.cleanupResources).toEqual([]);
if (kind === "helper")
expect(first.error.capturedOutput).toEqual(compilerOutput);
expect(blocked.error.cleanupResources).toEqual([]);
} finally {
denied = false;
await client.close();
}
},
);
it("keeps a physical version failure primary when cancellation arrives after the owner observed it", async () => {
const client = provider();
const controller = new AbortController();
vi.mocked(runOwnedCommand).mockImplementationOnce(async () => {
controller.abort({ caller: "after physical failure" });
throw new OwnedCommandFailure("process", "launcher exited 7");
});
try {
const result = await inspect(client, controller.signal);
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.error._tag).not.toBe("AnalysisCancelledError");
} finally {
await client.close();
}
});
it("finishes independent preparation before giving the version process its twenty-second deadline", async () => {
vi.useFakeTimers();
const client = provider();
const ready = createDeferred<void>();
vi.mocked(prepareOwnedCommand).mockImplementationOnce(() => ready.promise);
vi.mocked(runOwnedCommand).mockImplementation(async (_spawn, limits) => {
limits.observeCompleted?.(version);
return version;
});
const pending = inspect(client);
try {
await vi.advanceTimersByTimeAsync(21_000);
expect(runOwnedCommand).not.toHaveBeenCalled();
ready.resolve();
expect((await pending).ok).toBe(true);
expect(vi.mocked(runOwnedCommand).mock.lastCall?.[1].timeoutMs).toBe(
20_000,
);
} finally {
ready.resolve();
await pending;
await client.close();
}
});
it.each(["2.9.3", "not-a-version", "2.1.0"])(
"preserves a completed version report through cancellation during final cleanup: %s",
async (text) => {
const client = provider();
const controller = new AbortController();
const reason = { caller: "during final version cleanup" };
vi.mocked(runOwnedCommand).mockImplementationOnce(
async (_spawn, limits) => {
limits.observeCompleted?.({ ...version, stdout: { text } } as Awaited<
ReturnType<typeof runOwnedCommand>
>);
controller.abort(reason);
throw new OwnedCommandFailure(
"cancelled",
"authored finalization cancellation",
null,
null,
{ cancellation: { reason } },
);
},
);
try {
const result = await inspect(client, controller.signal);
if (result.ok) throw new Error("Expected finalization failure");
if (text === "2.9.3") {
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(reason);
expect(result.error.diagnostics).toMatchObject({
client: { version: text },
});
} else expect(result.error._tag).not.toBe("AnalysisCancelledError");
} finally {
await client.close();
}
},
);
it("keeps cleanup without a recovery capability unresolved and fences its workspace", async () => {
const { ApktoolCommandCleanup } = await import("./ApktoolCommandCleanup.js");
const commands = new ApktoolCommandCleanup();
const failure = commands.retainFailure(
new OwnedCommandFailure(
"process",
"launcher failed",
null,
"ownership unavailable",
undefined,
["authored-family"],
),
new ProviderAdapterError("apktool", "inspect_apktool_client"),
"decode_android_resources",
"/authored/workspace",
);
expect(failure.cleanupIncomplete).toBe(true);
expect(commands.pendingFor("/authored/workspace")).toBe(true);
expect(commands.pendingFor("/other/workspace")).toBe(false);
const acquire = vi.fn(async () => undefined);
await expect(
commands.run(acquire, "inspect_apktool_client"),
).rejects.toMatchObject({ cleanupIncomplete: true });
expect(acquire).not.toHaveBeenCalled();
await expect(commands.close()).rejects.toMatchObject({
cleanupIncomplete: true,
});
expect(failure.cleanupIncomplete).toBe(true);
expect(failure.cleanupResources).toEqual(["authored-family"]);
});
it("attributes prerequisite cleanup to decode and preserves its live recovery", async () => {
const client = provider();
const reason = { caller: "during decode prerequisite" };
const stop = vi.fn(async () => ({ status: "verified-cleanup" as const }));
vi.mocked(runOwnedCommand).mockRejectedValueOnce(
new OwnedCommandFailure(
"cancelled",
"cancelled",
null,
"stop denied",
{ cancellation: { reason } },
["family"],
{ stop } as unknown as ProviderProcessSupervisor,
),
);
try {
const result = await client.execute({
operation: "decode_android_resources",
input: {},
} as never);
if (result.ok) throw new Error("Expected prerequisite failure");
expect(result.error).toMatchObject({
operation: "decode_android_resources",
});
expect(result.error.cleanupPrimary?.cause).toBe(reason);
await client.close();
expect(result.error.cleanupIncomplete).toBe(false);
} finally {
await client.close();
}
});
it("retries failed command peers without repeating successful helper cleanup", async () => {
const { ApktoolCommandCleanup } = await import("./ApktoolCommandCleanup.js");
const commands = new ApktoolCommandCleanup();
const helper = new ProviderCleanupError("process-ownership", ["helper"], {});
const close = vi.fn(async () => {
helper.resolveCleanup();
});
let denied = true;
const stop = vi.fn(async () =>
denied
? { status: "incomplete" as const, reason: "denied" }
: { status: "verified-cleanup" as const },
);
const failure = commands.retainFailure(
new OwnedCommandFailure(
"process",
"failed",
null,
"denied",
{
preparationFailure: new DarwinProcessOwnershipInspectionError(
"helper failed",
{ cleanupFailure: helper, cleanupOwner: { close } },
),
},
["process"],
{ stop } as unknown as ProviderProcessSupervisor,
),
new ProviderAdapterError("apktool", "inspect_apktool_client"),
"inspect_apktool_client",
);
try {
await expect(commands.close()).rejects.toMatchObject({
cleanupIncomplete: true,
});
expect(failure.cleanupResources).toEqual(["process"]);
denied = false;
await commands.close();
expect(close).toHaveBeenCalledOnce();
expect(stop).toHaveBeenCalledTimes(2);
expect(failure.cleanupIncomplete).toBe(false);
expect(failure.cleanupResources).toEqual([]);
} finally {
denied = false;
await commands.close();
}
});
it("joins preparation during close and retains a late helper handoff", async () => {
const client = provider();
const ready = createDeferred<void>();
const cleanup = new ProviderCleanupError(
"process-ownership",
["late-helper"],
{},
);
const close = vi.fn(async () => {
cleanup.resolveCleanup();
});
vi.mocked(prepareOwnedCommand).mockImplementationOnce(async () => {
await ready.promise;
throw new DarwinProcessOwnershipInspectionError(
"physical preparation failure",
{ cleanupFailure: cleanup, cleanupOwner: { close } },
);
});
const pending = inspect(client);
await vi.waitFor(() => expect(prepareOwnedCommand).toHaveBeenCalledOnce());
let closed = false;
const closing = client.close().then(() => {
closed = true;
});
try {
await Promise.resolve();
expect(closed).toBe(false);
ready.resolve();
const result = await pending;
if (result.ok) throw new Error("Expected preparation failure");
expect(result.error._tag).not.toBe("AnalysisCancelledError");
await closing;
expect(close).toHaveBeenCalledOnce();
expect(result.error.cleanupIncomplete).toBe(false);
expect(runOwnedCommand).not.toHaveBeenCalled();
} finally {
ready.resolve();
await pending;
await closing;
}
});
it("preserves an opaque cancellation reason before acquiring a command", async () => {
const client = provider();
const reason = new ProviderCleanupError(
"foreign-owner",
["foreign-resource"],
{},
);
const controller = new AbortController();
controller.abort(reason);
try {
const result = await inspect(client, controller.signal);
if (result.ok) throw new Error("Expected cancellation");
expect(result.error.cause).toBe(reason);
expect(result.error.cleanupIncomplete).toBe(false);
expect(runOwnedCommand).not.toHaveBeenCalled();
expect(prepareOwnedCommand).not.toHaveBeenCalled();
expect(reason.cleanupIncomplete).toBe(true);
} finally {
await client.close();
}
});
+10 -4
View File
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ApktoolCommandCleanup } from "./ApktoolCommandCleanup.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
APKTOOL_COMMAND_ENV,
@@ -136,9 +137,8 @@ describe("apktoolInvocation", () => {
command: "C:\\tools & more\\apktool.cmd",
commandSource: "environment",
},
undefined,
windows,
"win32",
commands,
{ environment: windows, platform: "win32" },
).catch((cause: unknown) => cause);
expect(failure).toBeInstanceOf(ApktoolConfigurationFailure);
@@ -401,3 +401,9 @@ it("bounds the entire pending PATH lookup by the selection deadline", async () =
vi.useRealTimers();
}
});
let commands = new ApktoolCommandCleanup();
afterEach(async () => {
await commands.close();
commands = new ApktoolCommandCleanup();
});
+178 -80
View File
@@ -1,3 +1,10 @@
import { capturedApktoolCommandOutput } from "./ApktoolCommandOutput.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { ok, err, type Result } from "../domain/result.js";
import { ApktoolCommandCleanup } from "./ApktoolCommandCleanup.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js";
import {
AnalysisCancelledError,
AnalysisTimeoutError,
@@ -12,8 +19,8 @@ import { randomUUID } from "node:crypto";
import {
OwnedCommandFailure,
runOwnedCommand,
prepareOwnedCommand,
} from "../process/OwnedCommand.js";
import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js";
import type { ProviderAvailability } from "../application/AnalysisProvider.js";
import type { ProviderRejectionCode } from "../contracts/providerSelection.js";
import type { JsonValue } from "../domain/jsonValue.js";
@@ -47,7 +54,9 @@ export const resolveApktoolCommand = async (
signal?: AbortSignal,
): Promise<ApktoolCommandSelection> => {
if (signal?.aborted === true)
throw new AnalysisCancelledError("inspect_apktool_client");
throw new AnalysisCancelledError("inspect_apktool_client", {
cause: signal.reason,
});
const selectedEnvironment = snapshotEnvironment(environment, platform);
const configured = selectedEnvironment[APKTOOL_COMMAND_ENV];
if (configured !== undefined && configured.trim() !== "") {
@@ -88,7 +97,9 @@ export const resolveApktoolCommand = async (
const deadline = new ProviderStartupDeadline(VERSION_TIMEOUT_MS, signal);
const lookupInterruption = () =>
deadline.interruption === "cancelled"
? new AnalysisCancelledError("inspect_apktool_client")
? new AnalysisCancelledError("inspect_apktool_client", {
cause: signal?.reason,
})
: new AnalysisTimeoutError("inspect_apktool_client", VERSION_TIMEOUT_MS, {
diagnostics: { phase: "windows_path_lookup" },
});
@@ -154,23 +165,14 @@ export type ApktoolConfigurationFailureCode =
| "version_unresolved"
| "unsupported_version";
/** Unresolved cleanup of a launched probe; its owner was retained separately. */
export interface ApktoolProbeCleanup {
readonly reason: string;
readonly resources: readonly string[];
}
export class ApktoolConfigurationFailure extends Error {
readonly cleanup: ApktoolProbeCleanup | undefined;
constructor(
readonly code: ApktoolConfigurationFailureCode,
message: string,
readonly diagnostics: Readonly<Record<string, JsonValue>> = {},
options?: ErrorOptions & { readonly cleanup?: ApktoolProbeCleanup },
options?: ErrorOptions,
) {
super(message, options);
this.cleanup = options?.cleanup;
}
}
@@ -233,14 +235,21 @@ const supportsDocumentedApktoolVersion = (version: string): boolean => {
return parsedVersion !== null && gte(parsedVersion, "2.7.0");
};
/** Probe the selected apktool launcher without decoding anything. */
/** Probe within the caller-admitted command scope; decodes nothing. */
export const inspectApktoolClient = async (
selection: ApktoolCommandSelection,
signal?: AbortSignal,
environment: Readonly<Record<string, string | undefined>> = process.env,
platform: NodeJS.Platform = process.platform,
onCleanupOwner?: (owner: ProviderProcessSupervisor) => void,
cleanup: ApktoolCommandCleanup,
options: {
readonly signal?: AbortSignal;
readonly environment?: Readonly<Record<string, string | undefined>>;
readonly platform?: NodeJS.Platform;
} = {},
): Promise<ApktoolClientIdentity> => {
const {
signal,
environment = process.env,
platform = process.platform,
} = options;
const hostEnvironment = snapshotEnvironment(environment, platform);
let invocation: ApktoolInvocation;
try {
@@ -263,12 +272,14 @@ export const inspectApktoolClient = async (
{ cause },
);
}
let stdout: string;
let stderr: string;
let interpreted:
| Result<ApktoolClientIdentity, ApktoolConfigurationFailure>
| undefined;
try {
// The owned process group stops a shell launcher's children with it; a
// child holding inherited output pipes cannot outlive the deadline.
const output = await runOwnedCommand(
await prepareOwnedCommand(signal);
await runOwnedCommand(
{
command: invocation.command,
arguments: invocation.arguments,
@@ -282,72 +293,76 @@ export const inspectApktoolClient = async (
{
timeoutMs: VERSION_TIMEOUT_MS,
diagnosticBytes: VERSION_MAX_OUTPUT_BYTES,
observeCompleted: (snapshot) => {
try {
interpreted = ok(
interpretApktoolVersion(
snapshot.stdout.text,
snapshot.stderr.text,
selection,
),
);
} catch (cause) {
if (!(cause instanceof ApktoolConfigurationFailure)) throw cause;
interpreted = err(cause);
}
},
},
signal === undefined ? {} : { signal },
);
stdout = output.stdout.text;
stderr = output.stderr.text;
} catch (cause) {
if (
cause instanceof OwnedCommandFailure &&
cause.cleanupOwner !== undefined
)
onCleanupOwner?.(cause.cleanupOwner);
const cleanup =
cause instanceof OwnedCommandFailure && cause.cleanupFailure !== null
? { reason: cause.cleanupFailure, resources: cause.resources }
: undefined;
const cleanupDetails = cleanup === undefined ? {} : { cleanup };
if (
signal?.aborted === true ||
(cause instanceof OwnedCommandFailure && cause.reason === "cancelled")
)
throw new AnalysisCancelledError("inspect_apktool_client", {
cause,
...cleanupDetails,
});
if (cause instanceof OwnedCommandFailure && cause.reason === "timeout")
throw new AnalysisTimeoutError(
"inspect_apktool_client",
VERSION_TIMEOUT_MS,
{ cause, ...cleanupDetails },
);
if (
cause instanceof Error &&
"code" in cause &&
(cause.code === "ENOENT" || cause.code === "EACCES")
)
throw new ApktoolConfigurationFailure(
"command_missing",
`The selected apktool launcher was not found: ${selection.command}`,
{
selected_command: selection.command,
command_source: selection.commandSource,
remediation: `Set ${APKTOOL_COMMAND_ENV} to an absolute apktool launcher (the upstream wrapper or a java -jar script)`,
},
{ cause },
);
const snapshot =
cause instanceof OwnedCommandFailure ? cause.snapshot : null;
const recordedFailure =
interpreted?.ok === false ? interpreted.error : undefined;
const cancellation =
cause instanceof OwnedCommandFailure ? cause.cancellation : undefined;
const failure =
recordedFailure === undefined
? versionProbeFailure(
cause,
selection,
interpreted?.ok === true ? interpreted.value : undefined,
)
: new ApktoolConfigurationFailure(
recordedFailure.code,
recordedFailure.message,
{
...recordedFailure.diagnostics,
...(cancellation === undefined
? {}
: { cancellation_observed: true }),
},
{ cause },
);
const primary =
failure instanceof AnalysisError
? failure
: new ProviderAdapterError("apktool", "inspect_apktool_client", {
cause: failure,
userMessage: failure.message,
diagnostics: failure.diagnostics,
});
const retained = cleanup.retainFailure(
cause,
primary,
"inspect_apktool_client",
);
throw retained === primary ? failure : retained;
}
if (interpreted === undefined)
throw new ApktoolConfigurationFailure(
"version_unresolved",
"The selected apktool launcher did not report a usable version",
{
selected_command: selection.command,
...(snapshot === null
? {}
: {
exit_code: snapshot.exitCode ?? null,
stderr_excerpt: snapshot.stderr.text.slice(0, 512),
}),
...(cause instanceof OwnedCommandFailure &&
cause.reason === "output-limit"
? { max_output_bytes: VERSION_MAX_OUTPUT_BYTES }
: {}),
},
{ cause, ...cleanupDetails },
"The owned version command completed without an output observation",
{ selected_command: selection.command },
);
}
if (!interpreted.ok) throw interpreted.error;
return interpreted.value;
};
const interpretApktoolVersion = (
stdout: string,
stderr: string,
selection: ApktoolCommandSelection,
): ApktoolClientIdentity => {
const identity = parseApktoolVersionOutput(stdout);
if (identity.version === null)
throw new ApktoolConfigurationFailure(
@@ -400,3 +415,86 @@ export const apktoolConfigurationAvailability = (
reason: cause.message,
diagnostics: { ...cause.diagnostics },
}) as ProviderAvailability;
/** Interpret the command owner's outcome, without inferring a new failure from a late signal. */
const versionProbeFailure = (
cause: unknown,
selection: ApktoolCommandSelection,
observed: ApktoolClientIdentity | undefined,
): AnalysisError | ApktoolConfigurationFailure => {
if (cause instanceof AnalysisError) return cause;
const preparation =
cause instanceof OwnedCommandFailure
? cause.preparationFailure
: cause instanceof DarwinProcessOwnershipInspectionError
? cause
: undefined;
const output =
cause instanceof OwnedCommandFailure && cause.snapshot !== null
? capturedApktoolCommandOutput(cause.snapshot)
: preparation?.capturedOutput;
if (cause instanceof OwnedCommandFailure && cause.reason === "cancelled")
return new AnalysisCancelledError("inspect_apktool_client", {
cause: cause.cancellation?.reason,
...(output === undefined ? {} : { capturedOutput: output }),
...(observed === undefined ? {} : { diagnostics: { client: observed } }),
});
if (preparation !== undefined)
return new ProviderAdapterError("apktool", "inspect_apktool_client", {
cause,
diagnostics: {
stage: "ownership_preparation",
...(preparation.compilerOutcome === undefined
? {}
: {
compiler_outcome: jsonValueSchema.parse(
preparation.compilerOutcome,
),
}),
},
...(preparation.capturedOutput === undefined
? {}
: { capturedOutput: preparation.capturedOutput }),
});
if (cause instanceof OwnedCommandFailure && cause.reason === "timeout")
return new AnalysisTimeoutError(
"inspect_apktool_client",
VERSION_TIMEOUT_MS,
{ cause, ...(output === undefined ? {} : { capturedOutput: output }) },
);
if (
cause instanceof Error &&
"code" in cause &&
(cause.code === "ENOENT" || cause.code === "EACCES")
)
return new ApktoolConfigurationFailure(
"command_missing",
`The selected apktool launcher was not found: ${selection.command}`,
{
selected_command: selection.command,
command_source: selection.commandSource,
remediation: `Set ${APKTOOL_COMMAND_ENV} to an absolute apktool launcher (the upstream wrapper or a java -jar script)`,
},
{ cause },
);
const snapshot = cause instanceof OwnedCommandFailure ? cause.snapshot : null;
return new ApktoolConfigurationFailure(
"version_unresolved",
"The selected apktool launcher did not report a usable version",
{
selected_command: selection.command,
...(observed === undefined ? {} : { client: observed }),
...(snapshot === null
? {}
: {
exit_code: snapshot.exitCode ?? null,
stderr_excerpt: snapshot.stderr.text.slice(0, 512),
}),
...(cause instanceof OwnedCommandFailure &&
cause.reason === "output-limit"
? { max_output_bytes: VERSION_MAX_OUTPUT_BYTES }
: {}),
},
{ cause },
);
};
+104 -152
View File
@@ -1,3 +1,7 @@
import { capturedApktoolCommandOutput } from "./ApktoolCommandOutput.js";
import { ApktoolCommandCleanup } from "./ApktoolCommandCleanup.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { analysisClientFailureForOperation } from "../providers/AnalysisClientFailure.js";
import { regularFileCancellation } from "../filesystem/OwnedRegularFile.js";
import { createHash, randomUUID } from "node:crypto";
import { createWriteStream } from "node:fs";
@@ -39,7 +43,6 @@ import {
AnalysisTimeoutError,
AnalysisUnsupportedTargetError,
} from "../domain/analysisErrorCore.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { err, ok, type Result } from "../domain/result.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
@@ -51,13 +54,11 @@ import {
OwnedCommandFailure,
runOwnedCommand,
} from "../process/OwnedCommand.js";
import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js";
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
import { WindowsBatchCommandError } from "../process/WindowsBatchCommand.js";
import {
APKTOOL_COMMAND_ENV,
ApktoolConfigurationFailure,
type ApktoolProbeCleanup,
apktoolConfigurationAvailability,
apktoolInvocation,
inspectApktoolClient,
@@ -90,7 +91,8 @@ const throwIfCancelled = (
operation: string,
signal: AbortSignal | undefined,
): void => {
if (signal?.aborted === true) throw new AnalysisCancelledError(operation);
if (signal?.aborted === true)
throw new AnalysisCancelledError(operation, { cause: signal.reason });
};
/** Read at most the byte budget plus one byte, before decoding UTF-8. */
@@ -150,19 +152,6 @@ interface DecodeLimits {
readonly maxOutputBytes: number;
}
/** A probe whose launched processes could not be confirmed stopped. */
const probeCleanupFailure = (
operation: string,
failure: ApktoolConfigurationFailure,
cleanup: ApktoolProbeCleanup,
): ProviderCleanupError =>
new ProviderCleanupError(
APKTOOL_PROVIDER_IDENTITY.id,
cleanup.resources,
{ reason: cleanup.reason, probe_failure: failure.message },
{ operation, cause: failure },
);
const runApktool = async (
operation: string,
selection: ApktoolCommandSelection,
@@ -170,10 +159,13 @@ const runApktool = async (
limits: DecodeLimits,
signal: AbortSignal | undefined,
environment: Readonly<Record<string, string | undefined>>,
onCleanupOwner: (owner: ProviderProcessSupervisor) => void,
commands: ApktoolCommandCleanup,
root: string,
): Promise<Result<{ stdout: string; stderr: string }, AnalysisError>> => {
if (signal?.aborted === true)
return err(new AnalysisCancelledError(operation));
return err(
new AnalysisCancelledError(operation, { cause: signal?.reason }),
);
let invocation: ReturnType<typeof apktoolInvocation>;
try {
invocation = apktoolInvocation(selection, arguments_, environment);
@@ -209,77 +201,64 @@ const runApktool = async (
);
return ok({ stdout: output.stdout.text, stderr: output.stderr.text });
} catch (cause) {
if (!(cause instanceof OwnedCommandFailure))
return err(
new AnalysisProtocolError(`apktool failed for ${operation}`, { cause }),
);
if (cause.cleanupOwner !== undefined) onCleanupOwner(cause.cleanupOwner);
const output = cause.snapshot;
const capturedOutput =
output === null
? undefined
: {
stdout: output.stdout.text,
stderr: output.stderr.text,
truncated: output.diagnosticTruncated === true,
stdout_bytes: output.stdout.bytes,
stderr_bytes: output.stderr.bytes,
exit_code: output.exitCode ?? null,
signal: output.signal ?? null,
};
const cleanup =
cause.cleanupFailure === null
? undefined
: {
reason: cause.cleanupFailure,
resources: cause.resources,
};
if (cause.reason === "cancelled")
return err(
new AnalysisCancelledError(operation, {
cause,
...(cleanup === undefined ? {} : { cleanup }),
...(capturedOutput === undefined ? {} : { capturedOutput }),
}),
);
if (cause.reason === "timeout")
return err(
new AnalysisTimeoutError(operation, limits.timeoutMs, {
cause,
...(cleanup === undefined ? {} : { cleanup }),
...(capturedOutput === undefined ? {} : { capturedOutput }),
}),
);
if (cause.reason === "output-limit")
return err(
new AnalysisResourceConstraintError(
operation,
"memory",
`apktool output exceeded the ${String(limits.maxOutputBytes)}-byte capture budget`,
{ max_output_bytes: limits.maxOutputBytes },
{
cause,
...(cleanup === undefined ? {} : { cleanup }),
...(capturedOutput === undefined ? {} : { capturedOutput }),
},
),
);
return err(
new AnalysisUnsupportedTargetError(
commands.retainFailure(
cause,
apktoolCommandFailure(cause, operation, selection, limits),
operation,
selection.command,
output?.stderr.text.trim().slice(0, STDERR_EXCERPT_BYTES) ||
`apktool exited with code ${String(output?.exitCode ?? "unknown")} for ${operation}`,
{
cause,
...(cleanup === undefined ? {} : { cleanup }),
...(capturedOutput === undefined ? {} : { capturedOutput }),
},
root,
),
);
}
};
const apktoolCommandFailure = (
cause: unknown,
operation: string,
selection: ApktoolCommandSelection,
limits: DecodeLimits,
): AnalysisError => {
if (!(cause instanceof OwnedCommandFailure))
return new AnalysisProtocolError(`apktool failed for ${operation}`, {
cause,
});
const output = cause.snapshot;
const capturedOutput =
output === null ? undefined : capturedApktoolCommandOutput(output);
if (cause.reason === "cancelled")
return new AnalysisCancelledError(operation, {
cause: cause.cancellation?.reason,
...(capturedOutput === undefined ? {} : { capturedOutput }),
});
if (cause.reason === "timeout")
return new AnalysisTimeoutError(operation, limits.timeoutMs, {
cause,
...(capturedOutput === undefined ? {} : { capturedOutput }),
});
if (cause.reason === "output-limit")
return new AnalysisResourceConstraintError(
operation,
"memory",
`apktool output exceeded the ${String(limits.maxOutputBytes)}-byte capture budget`,
{ max_output_bytes: limits.maxOutputBytes },
{
cause,
...(capturedOutput === undefined ? {} : { capturedOutput }),
},
);
return new AnalysisUnsupportedTargetError(
operation,
selection.command,
output?.stderr.text.trim().slice(0, STDERR_EXCERPT_BYTES) ||
`apktool exited with code ${String(output?.exitCode ?? "unknown")} for ${operation}`,
{
cause,
...(capturedOutput === undefined ? {} : { capturedOutput }),
},
);
};
/** Count files while bounding streamed directory-entry traversal. */
const countDecodedFiles = async (
root: string,
@@ -345,11 +324,7 @@ export class ApktoolProvider {
private readonly workspaces = new Set<string>();
private readonly removeWorkspace: (path: string) => Promise<void>;
readonly #pendingCommandOwners = new Map<ProviderProcessSupervisor, string>();
/** A version probe whose cleanup is unresolved is retried on close. */
readonly #retainProbeOwner = (owner: ProviderProcessSupervisor): void => {
this.#pendingCommandOwners.set(owner, "apktool version probe");
};
readonly #commands = new ApktoolCommandCleanup();
readonly #shutdown = new AbortController();
readonly #active = new Set<Promise<void>>();
#closing = false;
@@ -387,7 +362,12 @@ export class ApktoolProvider {
signal === undefined
? this.#shutdown.signal
: AbortSignal.any([signal, this.#shutdown.signal]);
return this.#track(this.#inspectAvailability(operationSignal));
return this.#track(
this.#commands.run(
() => this.#inspectAvailability(operationSignal),
"inspect_apktool_client",
),
);
}
async #inspectAvailability(
@@ -400,13 +380,10 @@ export class ApktoolProvider {
process.platform,
signal,
);
await inspectApktoolClient(
selection,
await inspectApktoolClient(selection, this.#commands, {
signal,
environment,
process.platform,
this.#retainProbeOwner,
);
});
throwIfCancelled("inspect_apktool_client", signal);
return {
status: "available",
@@ -419,12 +396,6 @@ export class ApktoolProvider {
};
} catch (cause) {
if (cause instanceof ApktoolConfigurationFailure) {
if (cause.cleanup !== undefined)
throw probeCleanupFailure(
"inspect_apktool_client",
cause,
cause.cleanup,
);
return apktoolConfigurationAvailability(cause);
}
throw cause;
@@ -454,7 +425,19 @@ export class ApktoolProvider {
options?.signal === undefined
? this.#shutdown.signal
: AbortSignal.any([options.signal, this.#shutdown.signal]);
return this.#track(this.#execute(request, signal));
return this.#track(
this.#commands
.run(() => this.#execute(request, signal), request.operation)
.catch((cause: unknown) =>
err(
analysisClientFailureForOperation(
cause,
"apktool",
request.operation,
),
),
),
);
}
async #execute(
@@ -462,7 +445,9 @@ export class ApktoolProvider {
signal: AbortSignal,
): Promise<Result<AnalysisExecution, AnalysisError>> {
if (signal?.aborted === true)
return err(new AnalysisCancelledError(request.operation));
return err(
new AnalysisCancelledError(request.operation, { cause: signal.reason }),
);
const environment = this.operationEnvironment();
let selection: ApktoolCommandSelection;
let identity: ApktoolClientIdentity;
@@ -472,24 +457,20 @@ export class ApktoolProvider {
process.platform,
signal,
);
identity = await inspectApktoolClient(
selection,
identity = await inspectApktoolClient(selection, this.#commands, {
signal,
environment,
process.platform,
this.#retainProbeOwner,
);
});
} catch (cause) {
if (
cause instanceof AnalysisCancelledError ||
cause instanceof AnalysisTimeoutError
)
return err(cause);
if (cause instanceof AnalysisError)
return err(
analysisClientFailureForOperation(
cause,
"apktool",
request.operation,
),
);
if (cause instanceof ApktoolConfigurationFailure) {
if (cause.cleanup !== undefined)
return err(
probeCleanupFailure(request.operation, cause, cause.cleanup),
);
return err(
providerConfigurationRefusal(
APKTOOL_PROVIDER_IDENTITY.id,
@@ -506,7 +487,9 @@ export class ApktoolProvider {
);
}
if (signal.aborted)
return err(new AnalysisCancelledError(request.operation));
return err(
new AnalysisCancelledError(request.operation, { cause: signal.reason }),
);
const client = {
command: selection.command,
command_source: selection.commandSource,
@@ -541,40 +524,8 @@ export class ApktoolProvider {
async #drainAndClean(): Promise<void> {
await Promise.all(this.#active);
await this.#commands.close();
const failures: unknown[] = [];
const retained: string[] = [];
for (const [owner, root] of this.#pendingCommandOwners) {
try {
const stopped = await owner.stop();
if (stopped.status === "incomplete") throw new Error(stopped.reason);
this.#pendingCommandOwners.delete(owner);
} catch (cause) {
retained.push(root);
failures.push(
new Error(
`apktool process cleanup remains incomplete for ${root}: ${cause instanceof Error ? cause.message : String(cause)}`,
{ cause },
),
);
}
}
// Ownership stays retained for a later close; report it as typed cleanup.
if (failures.length > 0)
throw new ProviderCleanupError(
APKTOOL_PROVIDER_IDENTITY.id,
retained,
{
reason: failures
.map((failure) =>
failure instanceof Error ? failure.message : String(failure),
)
.join("; "),
},
{
operation: "close",
cause: new AggregateError(failures, "Apktool process cleanup failed"),
},
);
for (const path of this.workspaces) {
try {
await this.removeWorkspace(path);
@@ -613,7 +564,7 @@ export class ApktoolProvider {
// The workspace is removed before the result is returned, so an
// unremovable workspace is reported instead of silently abandoned.
const removeWorkspace = async (): Promise<void> => {
if ([...this.#pendingCommandOwners.values()].includes(root)) {
if (this.#commands.pendingFor(root)) {
cleanupFailure = `The decode workspace is retained until its apktool process stops: ${root}`;
return;
}
@@ -720,7 +671,8 @@ export class ApktoolProvider {
},
signal,
environment,
(owner) => this.#pendingCommandOwners.set(owner, root),
this.#commands,
root,
);
if (!decoded.ok) {
await removeWorkspace();
@@ -1,9 +1,10 @@
import { ApktoolCommandCleanup } from "./ApktoolCommandCleanup.js";
import { execFile } from "node:child_process";
import { chmod, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { promisify } from "node:util";
import { expect, it } from "vitest";
import { afterEach, expect, it } from "vitest";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
@@ -67,8 +68,8 @@ it.skipIf(process.platform === "win32")(
const started = performance.now();
const probe = inspectApktoolClient(
{ command: launcher, commandSource: "environment" },
controller.signal,
process.env,
commands,
{ signal: controller.signal, environment: process.env },
).catch((cause: unknown) => cause);
try {
expect(
@@ -105,3 +106,9 @@ it.skipIf(process.platform === "win32")(
},
30_000,
);
let commands = new ApktoolCommandCleanup();
afterEach(async () => {
await commands.close();
commands = new ApktoolCommandCleanup();
});
+56 -45
View File
@@ -1,3 +1,4 @@
import { ApktoolCommandCleanup } from "./ApktoolCommandCleanup.js";
import { afterEach, expect, it, vi } from "vitest";
import {
@@ -10,39 +11,49 @@ import {
runOwnedCommand,
} from "../process/OwnedCommand.js";
import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import {
ApktoolConfigurationFailure,
inspectApktoolClient,
} from "./ApktoolConfiguration.js";
import { inspectApktoolClient } from "./ApktoolConfiguration.js";
import { ApktoolProvider } from "./ApktoolProvider.js";
vi.mock("../process/OwnedCommand.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../process/OwnedCommand.js")>()),
runOwnedCommand: vi.fn(),
}));
afterEach(() => vi.mocked(runOwnedCommand).mockReset());
let commands = new ApktoolCommandCleanup();
afterEach(async () => {
await commands.close();
commands = new ApktoolCommandCleanup();
vi.mocked(runOwnedCommand).mockReset();
});
const selection = {
command: "/opt/apktool/apktool",
commandSource: "environment",
} as const;
const owner = {} as ProviderProcessSupervisor;
const owner = {
stop: vi.fn(async () => ({ status: "verified-cleanup" as const })),
} as unknown as ProviderProcessSupervisor;
it("runs the version probe as an owned interpreter-script process group", async () => {
vi.mocked(runOwnedCommand).mockResolvedValueOnce({
stdout: { text: "2.9.3\n" },
stderr: { text: "" },
} as Awaited<ReturnType<typeof runOwnedCommand>>);
vi.mocked(runOwnedCommand).mockImplementationOnce(async (_spawn, limits) => {
const output = {
stdout: { text: "2.9.3\n" },
stderr: { text: "" },
} as Awaited<ReturnType<typeof runOwnedCommand>>;
limits.observeCompleted?.(output);
return output;
});
await expect(
inspectApktoolClient(selection, undefined, {}, "linux"),
inspectApktoolClient(selection, commands, {
environment: {},
platform: "linux",
}),
).resolves.toEqual({ version: "2.9.3" });
expect(vi.mocked(runOwnedCommand).mock.lastCall?.[0]).toMatchObject({
command: "/opt/apktool/apktool",
arguments: ["--version"],
expectedCommand: null,
});
expect(vi.mocked(runOwnedCommand).mock.lastCall?.[1]).toEqual({
expect(vi.mocked(runOwnedCommand).mock.lastCall?.[1]).toMatchObject({
timeoutMs: 20_000,
diagnosticBytes: 64 * 1024,
});
@@ -52,12 +63,10 @@ it("reports an expired version probe as a typed timeout", async () => {
vi.mocked(runOwnedCommand).mockRejectedValueOnce(
new OwnedCommandFailure("timeout", "Command deadline elapsed."),
);
const failure = await inspectApktoolClient(
selection,
undefined,
{},
"linux",
).catch((cause: unknown) => cause);
const failure = await inspectApktoolClient(selection, commands, {
environment: {},
platform: "linux",
}).catch((cause: unknown) => cause);
expect(failure).toBeInstanceOf(AnalysisTimeoutError);
expect(projectAnalysisError(failure as AnalysisTimeoutError)).toMatchObject({
code: "provider_timeout",
@@ -78,19 +87,19 @@ it("retains an unresolved probe cleanup owner and reports the cleanup", async ()
owner,
),
);
const retained: ProviderProcessSupervisor[] = [];
const failure = await inspectApktoolClient(
selection,
undefined,
{},
"linux",
(cleanupOwner) => retained.push(cleanupOwner),
).catch((cause: unknown) => cause);
expect(failure).toBeInstanceOf(AnalysisTimeoutError);
const failure = await inspectApktoolClient(selection, commands, {
environment: {},
platform: "linux",
}).catch((cause: unknown) => cause);
expect((failure as AnalysisTimeoutError).cleanupPrimary).toBeInstanceOf(
AnalysisTimeoutError,
);
expect(projectAnalysisError(failure as AnalysisTimeoutError)).toMatchObject({
code: "cleanup_incomplete",
});
expect(retained).toEqual([owner]);
expect(owner.stop).not.toHaveBeenCalled();
await commands.close();
expect((failure as AnalysisTimeoutError).cleanupIncomplete).toBe(false);
});
it("reports a cancelled version probe as cancelled", async () => {
@@ -98,7 +107,10 @@ it("reports a cancelled version probe as cancelled", async () => {
new OwnedCommandFailure("cancelled", "Command cancelled."),
);
await expect(
inspectApktoolClient(selection, undefined, {}, "linux"),
inspectApktoolClient(selection, commands, {
environment: {},
platform: "linux",
}),
).rejects.toBeInstanceOf(AnalysisCancelledError);
});
@@ -114,34 +126,33 @@ it("keeps unresolved probe cleanup typed when the launcher overflows its output"
owner,
);
vi.mocked(runOwnedCommand).mockRejectedValueOnce(overflow());
const failure = await inspectApktoolClient(
selection,
undefined,
{},
"linux",
).catch((cause: unknown) => cause);
expect(failure).toBeInstanceOf(ApktoolConfigurationFailure);
expect((failure as ApktoolConfigurationFailure).cleanup).toEqual({
reason: "process group still running",
resources: ["pid:7", "process-group:7"],
});
const failure = await inspectApktoolClient(selection, commands, {
environment: {},
platform: "linux",
}).catch((cause: unknown) => cause);
expect((failure as AnalysisTimeoutError).cleanupResources).toEqual([
"pid:7",
"process-group:7",
]);
// Both provider entry points report the unresolved processes as cleanup.
const provider = new ApktoolProvider({
environment: { REA_APKTOOL_COMMAND: "/opt/apktool/apktool" },
});
vi.mocked(runOwnedCommand).mockRejectedValueOnce(overflow());
await expect(provider.inspectAvailability()).rejects.toBeInstanceOf(
ProviderCleanupError,
);
await expect(provider.inspectAvailability()).rejects.toMatchObject({
cleanupIncomplete: true,
});
vi.mocked(runOwnedCommand).mockRejectedValueOnce(overflow());
const result = await provider.execute({
operation: "decode_android_resources",
input: {},
} as never);
expect(result.ok ? null : result.error).toBeInstanceOf(ProviderCleanupError);
expect(result.ok ? null : result.error.cleanupIncomplete).toBe(true);
expect(result.ok ? null : projectAnalysisError(result.error)).toMatchObject({
code: "cleanup_incomplete",
details: { resources: ["pid:7", "process-group:7"] },
});
await provider.close();
expect(result.ok ? null : result.error.cleanupIncomplete).toBe(false);
});