diff --git a/src/apktool/ApktoolCommandCleanup.ts b/src/apktool/ApktoolCommandCleanup.ts new file mode 100644 index 0000000000..f720ef2806 --- /dev/null +++ b/src/apktool/ApktoolCommandCleanup.ts @@ -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(); + + async run(operation: () => Promise, name: string): Promise { + try { + return await this.#scope.run(() => { + this.#prune(); + return operation(); + }); + } catch (cause) { + throw this.#retryFailure(cause, name); + } + } + + async close(): Promise { + 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, + 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); + } +} diff --git a/src/apktool/ApktoolCommandOutput.ts b/src/apktool/ApktoolCommandOutput.ts new file mode 100644 index 0000000000..3dd0e47e0a --- /dev/null +++ b/src/apktool/ApktoolCommandOutput.ts @@ -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, +}); diff --git a/src/apktool/ApktoolCommandRecovery.test.ts b/src/apktool/ApktoolCommandRecovery.test.ts new file mode 100644 index 0000000000..1c1ea3c1fe --- /dev/null +++ b/src/apktool/ApktoolCommandRecovery.test.ts @@ -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()), + 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>; +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(); + 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 + >); + 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(); + 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(); + } +}); diff --git a/src/apktool/ApktoolConfiguration.test.ts b/src/apktool/ApktoolConfiguration.test.ts index aae9d9ed1a..df7dccd6e0 100644 --- a/src/apktool/ApktoolConfiguration.test.ts +++ b/src/apktool/ApktoolConfiguration.test.ts @@ -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(); +}); diff --git a/src/apktool/ApktoolConfiguration.ts b/src/apktool/ApktoolConfiguration.ts index cacacf404b..43723e8735 100644 --- a/src/apktool/ApktoolConfiguration.ts +++ b/src/apktool/ApktoolConfiguration.ts @@ -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 => { 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> = {}, - 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> = process.env, - platform: NodeJS.Platform = process.platform, - onCleanupOwner?: (owner: ProviderProcessSupervisor) => void, + cleanup: ApktoolCommandCleanup, + options: { + readonly signal?: AbortSignal; + readonly environment?: Readonly>; + readonly platform?: NodeJS.Platform; + } = {}, ): Promise => { + 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 + | 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 }, + ); +}; diff --git a/src/apktool/ApktoolProvider.ts b/src/apktool/ApktoolProvider.ts index 84d1c8d2fd..8e4c5c288e 100644 --- a/src/apktool/ApktoolProvider.ts +++ b/src/apktool/ApktoolProvider.ts @@ -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>, - onCleanupOwner: (owner: ProviderProcessSupervisor) => void, + commands: ApktoolCommandCleanup, + root: string, ): Promise> => { if (signal?.aborted === true) - return err(new AnalysisCancelledError(operation)); + return err( + new AnalysisCancelledError(operation, { cause: signal?.reason }), + ); let invocation: ReturnType; 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(); private readonly removeWorkspace: (path: string) => Promise; - readonly #pendingCommandOwners = new Map(); - /** 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>(); #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> { 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 { 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 => { - 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(); diff --git a/src/apktool/ApktoolVersionProbe.lifecycle.test.ts b/src/apktool/ApktoolVersionProbe.lifecycle.test.ts index 6abe196495..b77ad65f10 100644 --- a/src/apktool/ApktoolVersionProbe.lifecycle.test.ts +++ b/src/apktool/ApktoolVersionProbe.lifecycle.test.ts @@ -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(); +}); diff --git a/src/apktool/ApktoolVersionProbe.test.ts b/src/apktool/ApktoolVersionProbe.test.ts index 616157ba6b..f2f9b42911 100644 --- a/src/apktool/ApktoolVersionProbe.test.ts +++ b/src/apktool/ApktoolVersionProbe.test.ts @@ -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()), 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>); + vi.mocked(runOwnedCommand).mockImplementationOnce(async (_spawn, limits) => { + const output = { + stdout: { text: "2.9.3\n" }, + stderr: { text: "" }, + } as Awaited>; + 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); });