Files
rea/src/apktool/ApktoolCommandRecovery.test.ts
T
morlutoandPeter cc038da218 fix(apktool): attribute decode refusals to the selected APK
Carry caller-selected target identity separately from launcher selection. Only fully collected ordinary nonzero decode exits represent target refusals; startup, preparation, capture and signal failures retain provider attribution. Reuse one command/preparation output projection for probe and decode errors.

Adapt the target-path correction from PR #3462 (08d3d0d3a0) and extend its producer-boundary classification and regression coverage.

Validation: 131 focused tests in 12 files, final 50 source checks, build, typecheck/lint and docs. Ten regression controls fail on baseline 4844ae812. Twenty-four tiny actual CLI/stdio MCP refusal tasks preserve every projected field except the corrected message/path. Authored launchers only; no real Apktool/JVM or native Windows claim. Heavy stress/profiling remained stopped.

Closes #3340

Co-authored-by: Peter <304086198+peter-research@users.noreply.github.com>
2026-10-17 22:44:00 +08:00

478 lines
16 KiB
TypeScript

import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
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();
}
});
it.each([
"refusal",
"spawn",
"collection",
"signal",
"incomplete",
"preparation",
] as const)(
"keeps target identity separate from a %s command outcome",
async (kind) => {
const root = await createTestTempDirectory("rea-apktool-attribution-");
const path = join(root, "caller-selected.apk");
await writeFile(path, "authored input");
const client = provider();
const output = {
stdout: { text: "authored stdout", bytes: 15, observedBytes: 15 },
stderr: { text: "authored decode refusal", bytes: 23, observedBytes: 23 },
exitCode: 1,
signal: null,
diagnosticTruncated: false,
} satisfies Awaited<ReturnType<typeof runOwnedCommand>>;
const compiler = {
stdout: "compiler output",
stderr: "compiler diagnostic",
truncated: false,
stdout_bytes: 15,
stderr_bytes: 19,
exit_code: 1,
signal: null,
};
const failure =
kind === "preparation"
? new DarwinProcessOwnershipInspectionError(
"authored preparation failure",
{ capturedOutput: compiler },
)
: new OwnedCommandFailure(
"process",
"authored command failure",
{
...output,
...(kind === "signal"
? { exitCode: null, signal: "SIGTERM" }
: {}),
...(kind === "incomplete" ? { outputIncomplete: true } : {}),
},
null,
{
...(kind === "spawn"
? { processError: new Error("authored startup failure") }
: {}),
...(kind === "collection" ? { collectionFailure: true } : {}),
},
);
vi.mocked(runOwnedCommand)
.mockImplementationOnce(async (_spawn, limits) => {
limits.observeCompleted?.(version);
return version;
})
.mockRejectedValueOnce(failure);
try {
const result = await client.execute({
operation: "decode_android_resources",
input: { path, include_strings: true },
});
if (result.ok) throw new Error("Expected authored failure");
if (kind === "refusal") {
expect(result.error).toMatchObject({
_tag: "AnalysisUnsupportedTargetError",
path,
});
expect(result.error.message).toContain(path);
expect(result.error.message).not.toContain("/authored/apktool");
} else
expect(result.error).toMatchObject({
_tag: "ProviderAdapterError",
diagnostics: {
target_path: path,
selected_command: "/authored/apktool",
},
});
expect(result.error.capturedOutput).toMatchObject(
kind === "preparation"
? compiler
: { stdout: output.stdout.text, stderr: output.stderr.text },
);
expect(result.error.partialObservation).toMatchObject({
kind: "file-snapshot-hash",
path,
});
} finally {
await client.close();
}
},
);