mirror of
https://github.com/morluto/rea.git
synced 2026-10-31 20:06:14 +00:00
* fix(apktool): own the version probe's process group and keep its timeout typed The version probe ran its launcher through execFileOutput, which waits for every inherited output pipe to close. A shell launcher's child that holds those pipes outlived the stop request, so the probe stayed pending past its deadline and its grace period, and cancellation hung the same way. An expired probe also surfaced as a non-retryable unusable-version refusal. Run the probe through runOwnedCommand like the decode run, so the owned process group is stopped with its children. Report its deadline as AnalysisTimeoutError with timeout_ms and retain an unresolved cleanup owner for close. Apktool launchers are interpreter scripts, so both the probe and the decode run prove ownership by run token instead of the launcher's argv prefix. A close with unresolved process cleanup reports a typed ProviderCleanupError instead of an untyped AggregateError. Fixes #3342 Fixes #3347 Fixes #3337 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(apktool): carry unresolved probe cleanup through both provider entry points A probe failure other than cancellation or timeout, such as an output overflow whose launcher child survives, now keeps its cleanup reason and resources on ApktoolConfigurationFailure. inspectAvailability and execute report it as ProviderCleanupError instead of ordinary unavailability or capability_unavailable. Add a real-process lifecycle test: an interpreter launcher starts a child that holds the inherited output pipes, the probe is cancelled after launch, and both processes must be stopped within the bound. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(apktool): ignore zombies and always clean up the probe lifecycle fixture Treat a zombie as exited when checking the launcher and child, bound the cancellation wait so a regressed probe still reaches cleanup, and stop any remaining fixture processes in a finally block. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(apktool): treat a failed process inspection as unknown, not stopped Only ESRCH, a zombie state or ps reporting no match counts as exited; a ps launch failure leaves the process presumed live so the assertion fails and the finally block still stops it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
108 lines
3.8 KiB
TypeScript
108 lines
3.8 KiB
TypeScript
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 { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
|
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
|
import { inspectApktoolClient } from "./ApktoolConfiguration.js";
|
|
|
|
/**
|
|
* Only an exited process counts as stopped: it is gone, a zombie waiting to
|
|
* be reaped, or absent from `ps`. A failed inspection is not proof of exit.
|
|
*/
|
|
const alive = async (pid: number): Promise<boolean> => {
|
|
try {
|
|
process.kill(pid, 0);
|
|
} catch (cause: unknown) {
|
|
if ((cause as NodeJS.ErrnoException).code === "ESRCH") return false;
|
|
return true;
|
|
}
|
|
return promisify(execFile)("ps", ["-o", "stat=", "-p", `${pid}`]).then(
|
|
({ stdout }) => {
|
|
const state = stdout.trim();
|
|
return state !== "" && !state.startsWith("Z");
|
|
},
|
|
// `ps` exits 1 when no process matches; any other failure is unknown.
|
|
(cause: unknown) => (cause as { code?: unknown }).code !== 1,
|
|
);
|
|
};
|
|
|
|
const waitFor = async (check: () => Promise<boolean>, ms: number) => {
|
|
const deadline = Date.now() + ms;
|
|
while (Date.now() < deadline) {
|
|
if (await check()) return true;
|
|
await delay(25);
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const readPids = async (path: string): Promise<readonly number[]> =>
|
|
readFile(path, "utf8")
|
|
.then((text) => JSON.parse(text) as number[])
|
|
.catch(() => []);
|
|
|
|
// The interpreter is the test runtime itself, so its environment (and the
|
|
// ownership run token) is readable on both Linux and macOS.
|
|
it.skipIf(process.platform === "win32")(
|
|
"stops an interpreter launcher and its pipe-holding child when the probe is cancelled",
|
|
async () => {
|
|
const root = await createTestTempDirectory("rea-apktool-probe-");
|
|
const pids = join(root, "pids.json");
|
|
const launcher = join(root, "apktool");
|
|
await writeFile(
|
|
launcher,
|
|
[
|
|
`#!${process.execPath}`,
|
|
'const { spawn } = require("node:child_process");',
|
|
'const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "inherit" });',
|
|
`require("node:fs").writeFileSync(${JSON.stringify(pids)}, JSON.stringify([process.pid, child.pid]));`,
|
|
"setInterval(() => {}, 1000);",
|
|
].join("\n"),
|
|
);
|
|
await chmod(launcher, 0o755);
|
|
const controller = new AbortController();
|
|
const started = performance.now();
|
|
const probe = inspectApktoolClient(
|
|
{ command: launcher, commandSource: "environment" },
|
|
controller.signal,
|
|
process.env,
|
|
).catch((cause: unknown) => cause);
|
|
try {
|
|
expect(
|
|
await waitFor(async () => (await readPids(pids)).length === 2, 10_000),
|
|
).toBe(true);
|
|
const launched = await readPids(pids);
|
|
controller.abort();
|
|
// A regressed probe never settles; bound the wait so cleanup still runs.
|
|
const outcome = await Promise.race([
|
|
probe,
|
|
delay(15_000).then(() => "still pending"),
|
|
]);
|
|
expect(outcome).toBeInstanceOf(AnalysisCancelledError);
|
|
expect(performance.now() - started).toBeLessThan(15_000);
|
|
expect(
|
|
await waitFor(
|
|
async () =>
|
|
(await Promise.all(launched.map(alive))).every((live) => !live),
|
|
5_000,
|
|
),
|
|
).toBe(true);
|
|
} finally {
|
|
// A failed assertion must not leave the fixture's processes running.
|
|
controller.abort();
|
|
await Promise.race([probe, delay(10_000)]);
|
|
for (const pid of await readPids(pids))
|
|
if (await alive(pid))
|
|
try {
|
|
process.kill(pid, "SIGKILL");
|
|
} catch {
|
|
// Already exited between the check and the signal.
|
|
}
|
|
}
|
|
},
|
|
30_000,
|
|
);
|