Files
rea/tests/process-global/pwntoolsCleanup.test.ts
T
Yijie Luandmorluto 11fe5a13f8 fix(native): retain failed pwntools process and runtime cleanup (#1708)
* fix(native): retain failed pwntools process and runtime cleanup

Keep failed worker/debugger cleanup owners and their private files until
verified process shutdown and successful directory removal. Retry cleanup
before another inspection and expose shutdown through the shared services,
CLI and MCP server. Serialize acquisition without delaying cancellation of
queued callers, and preserve primary diagnostics when CLI cleanup fails.

The original decoder reproduction left a live owned Node child with its
runtime deleted and no retained cleanup owner; the regression now verifies
retention, admission blocking, retries and eventual process/file cleanup.

Validation: 10 lifecycle/public CLI/MCP tests, 20 service tests, test
discovery 814/814, build:cached and full check including architecture guards.
Actual POSIX process/filesystem boundaries used injected Node workers on
macOS; real Linux pwntools/GDB provider parsing was not exercised.

* test(native): verify retained recorded-crash cleanup lifecycle

* fix(native): finalize diagnostic cleanup before publishing results

* fix(native): retire decoder stages before reading runtime evidence

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 01:37:47 +08:00

265 lines
8.6 KiB
TypeScript

import { access, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import {
afterEach,
beforeAll,
beforeEach,
expect,
it,
onTestFinished,
} from "vitest";
import { PwntoolsLayoutProvider } from "../../src/native/pwntools/PwntoolsLayoutProvider.js";
import { PrivateRuntimeRoot } from "../../src/process/PrivateRuntimeRoot.js";
import { spawnOwnedProviderProcess } from "../../src/process/ProviderProcess.js";
import { cleanupOwnedProcessGroup } from "../../src/process/ProcessOwnership.js";
import { prepareProcessOwnershipInspection } from "../../src/process/ProcessOwnershipObservation.js";
import { stopProviderProcessFixture } from "../fixtures/providerProcess.js";
import {
createTestWorkspace,
removeTestWorkspace,
} from "../support/workspace/workspaceFixture.js";
const platform = Object.getOwnPropertyDescriptor(process, "platform");
const architecture = Object.getOwnPropertyDescriptor(process, "arch");
const supportedHost = process.platform !== "win32";
beforeAll(() =>
supportedHost ? prepareProcessOwnershipInspection() : undefined,
);
// Only the decoder's availability gate is simulated. The module-owned process
// inspector was created and prepared for the real host before these overrides;
// every fixture launch, output stream, cleanup signal and filesystem is real.
beforeEach(() => {
Object.defineProperty(process, "platform", { value: "linux" });
Object.defineProperty(process, "arch", { value: "x64" });
});
afterEach(() => {
if (platform !== undefined)
Object.defineProperty(process, "platform", platform);
if (architecture !== undefined)
Object.defineProperty(process, "arch", architecture);
});
const fixture = async () => {
const workspace = await createTestWorkspace("rea-pwntools-cleanup-");
onTestFinished(() => removeTestWorkspace(workspace.root));
const path = join(workspace.root, "selected.elf");
await writeFile(path, "source-owned-seam-bytes");
return { workspace, path };
};
it.runIf(supportedHost)(
"retains live worker files, refuses another launch, and retries cleanup on close",
async () => {
const { workspace, path } = await fixture();
let ownedPath = "";
let pid: number | undefined;
let launches = 0;
let cleanupAllowed = false;
let releaseFixture: (() => Promise<void>) | undefined;
const provider = new PwntoolsLayoutProvider(
{ REA_PWNTOOLS_PYTHON: process.execPath },
async (input) => {
launches++;
ownedPath = input.cwd ?? "";
const launched = await spawnOwnedProviderProcess({
...input,
arguments: [
"-e",
'process.stdout.write("x".repeat(1048577)); setInterval(() => undefined, 1000)',
],
});
pid = launched.process.pid;
releaseFixture = () => stopProviderProcessFixture(launched.process);
return {
...launched,
cleanup: () =>
cleanupAllowed
? cleanupOwnedProcessGroup(launched.ownership)
: Promise.resolve({
cleaned: false as const,
reason: "fixture process inspection denied",
}),
};
},
() =>
PrivateRuntimeRoot.create({ parent: workspace.root, prefix: "owned-" }),
);
onTestFinished(async () => {
cleanupAllowed = true;
await provider.close();
await releaseFixture?.();
});
const result = await provider.inspect({ path });
if (result.ok)
throw new Error("Expected output overflow and failed cleanup");
expect(result.error).toMatchObject({
cleanupIncomplete: true,
diagnostics: {
primary_error: {
code: "unreadable_output",
details: { captured_output: { truncated: true } },
},
cleanup_error: { code: "cleanup_incomplete" },
},
});
if (pid === undefined)
throw new Error("The fixture did not acquire a process");
const workerPid = pid;
expect(() => process.kill(workerPid, 0)).not.toThrow();
expect(await readFile(join(ownedPath, "object.snapshot"), "utf8")).toBe(
"source-owned-seam-bytes",
);
const refused = await provider.inspect({ path });
if (refused.ok)
throw new Error("Unreleased ownership must block another launch");
expect(refused.error.cleanupResources).toContain(ownedPath);
expect(launches).toBe(1);
await expect(provider.close()).rejects.toMatchObject({
cleanupIncomplete: true,
});
await expect(access(ownedPath)).resolves.toBeUndefined();
cleanupAllowed = true;
await provider.close();
expect(() => process.kill(workerPid, 0)).toThrow();
await expect(access(ownedPath)).rejects.toMatchObject({ code: "ENOENT" });
},
);
it.runIf(supportedHost)(
"retains a failed root removal and retries it before acquiring another decoder",
async () => {
const { workspace, path } = await fixture();
let releases = 0;
let launches = 0;
let removalAllowed = false;
const root = await PrivateRuntimeRoot.create({
parent: workspace.root,
prefix: "owned-",
});
const provider = new PwntoolsLayoutProvider(
{ REA_PWNTOOLS_PYTHON: process.execPath },
async (input) => {
launches++;
return spawnOwnedProviderProcess({
...input,
arguments: [
"-e",
'process.stdout.write("retained diagnostic"); process.exitCode = 2',
],
});
},
async () => ({
path: root.path,
close: async () => {
releases++;
if (!removalAllowed) throw new Error("fixture root removal denied");
await root.close();
},
}),
);
onTestFinished(async () => {
removalAllowed = true;
await provider.close();
await root.close();
});
const result = await provider.inspect({ path });
if (result.ok)
throw new Error("Expected command and root-removal failures");
expect(result.error).toMatchObject({
cleanupIncomplete: true,
diagnostics: {
primary_error: {
code: "execution_failure",
details: { diagnostics: { stdout: "retained diagnostic" } },
},
cleanup_error: { code: "cleanup_incomplete" },
},
});
const refused = await provider.inspect({ path });
if (refused.ok)
throw new Error("Retained root cleanup must precede a new launch");
expect(refused.error.cleanupResources).toEqual([root.path]);
expect(launches).toBe(1);
expect(releases).toBe(2);
removalAllowed = true;
await provider.close();
await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" });
},
);
it.runIf(supportedHost)(
"cancels queued inspections promptly and drains the running inspection when closing",
async () => {
const { workspace, path } = await fixture();
let release: (() => void) | undefined;
let entered: (() => void) | undefined;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const started = new Promise<void>((resolve) => {
entered = resolve;
});
let launches = 0;
const provider = new PwntoolsLayoutProvider(
{ REA_PWNTOOLS_PYTHON: process.execPath },
async (input) => {
launches++;
entered?.();
await gate;
return spawnOwnedProviderProcess({
...input,
arguments: ["-e", "process.exit(2)"],
});
},
() =>
PrivateRuntimeRoot.create({ parent: workspace.root, prefix: "owned-" }),
);
onTestFinished(async () => {
release?.();
await provider.close();
});
const first = provider.inspect({ path });
await started;
const controller = new AbortController();
const cancelled = provider.inspect({ path }, { signal: controller.signal });
controller.abort();
await expect(cancelled).resolves.toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
const queued = provider.inspect({ path });
const closing = provider.close();
const late = provider.inspect({ path });
release?.();
const [firstResult, queuedResult, lateResult] = await Promise.all([
first,
queued,
late,
]);
if (firstResult.ok) throw new Error("The fixture exits with failure");
expect(firstResult.error).not.toMatchObject({ cleanupIncomplete: true });
expect(queuedResult).toMatchObject({
ok: false,
error: {
_tag: "AnalysisCapabilityUnavailableError",
cleanupIncomplete: false,
},
});
expect(lateResult).toMatchObject({
ok: false,
error: {
_tag: "AnalysisCapabilityUnavailableError",
cleanupIncomplete: false,
},
});
await closing;
expect(launches).toBe(1);
},
);