Files
rea/tests/process-global/pwntoolsCliFinalization.test.ts
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

309 lines
9.8 KiB
TypeScript

import { z } from "zod";
import { access, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import {
afterEach,
beforeAll,
beforeEach,
expect,
it,
onTestFinished,
vi,
} from "vitest";
import { BinaryLayoutService } from "../../src/application/binaryDiagnostics/BinaryLayoutService.js";
import { createCli } from "../../src/cli.js";
import * as composition from "../../src/composition/binaryDiagnostics.js";
import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js";
import { PwntoolsLayoutProvider } from "../../src/native/pwntools/PwntoolsLayoutProvider.js";
import type { PwntoolsLauncher } from "../../src/native/pwntools/PwntoolsDecoder.js";
import { PWNTOOLS_PROVIDER_IDENTITY } from "../../src/native/pwntools/PwntoolsRelease.js";
import { PrivateRuntimeRoot } from "../../src/process/PrivateRuntimeRoot.js";
import { cleanupOwnedProcessGroup } from "../../src/process/ProcessOwnership.js";
import { prepareProcessOwnershipInspection } from "../../src/process/ProcessOwnershipObservation.js";
import { spawnOwnedProviderProcess } from "../../src/process/ProviderProcess.js";
import { binaryLayoutFixture } from "../fixtures/binaryDiagnostics/layout.js";
import {
createTestWorkspace,
removeTestWorkspace,
} from "../support/workspace/workspaceFixture.js";
const supportedHost = process.platform !== "win32";
const platform = Object.getOwnPropertyDescriptor(process, "platform");
const architecture = Object.getOwnPropertyDescriptor(process, "arch");
const exitCode = process.exitCode;
beforeAll(() =>
supportedHost ? prepareProcessOwnershipInspection() : undefined,
);
beforeEach(() => {
// Prepare the real host inspector first; only decoder availability is simulated.
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);
process.exitCode = exitCode;
vi.restoreAllMocks();
});
const fixture = async (outcome: "decoded" | "failed" = "decoded") => {
const workspace = await createTestWorkspace("rea-pwntools-finalization-");
onTestFinished(() => removeTestWorkspace(workspace.root));
const path = workspace.path("selected.elf");
await writeFile(path, Buffer.alloc(128));
const root = await PrivateRuntimeRoot.create({
parent: workspace.root,
prefix: "owned-",
});
const {
artifact: _artifact,
diagnostics: _diagnostics,
...payload
} = binaryLayoutFixture(path);
const launch: PwntoolsLauncher = async (input) => {
const request = z
.object({ reply_path: z.string() })
.parse(
JSON.parse(await readFile(join(root.path, "request.json"), "utf8")),
);
return spawnOwnedProviderProcess({
...input,
arguments: [
"-e",
outcome === "decoded"
? 'require("node:fs").writeFileSync(process.argv[1], process.argv[2]); process.stdout.write("decoder output")'
: 'process.stdout.write("decoder output"); process.exitCode = 2',
request.reply_path,
JSON.stringify({
ok: true,
profile: PWNTOOLS_PROVIDER_IDENTITY.version,
value: payload,
}),
],
});
};
return { path, root, launch };
};
const runCli = async (
provider: PwntoolsLayoutProvider,
path: string,
): Promise<unknown> => {
vi.spyOn(composition, "createBinaryLayoutService").mockReturnValue(
new BinaryLayoutService(provider),
);
let stdout = "";
await createCli({}).serve(["inspect-binary-layout", path, "--json"], {
env: {},
stdout: (text) => {
stdout += text;
},
exit: () => undefined,
});
return JSON.parse(stdout);
};
it.runIf(supportedHost).each(["decoded", "failed"] as const)(
"returns the original %s outcome after a successful runtime-removal retry through the CLI",
async (outcome) => {
const { path, root, launch } = await fixture(outcome);
let removals = 0;
const provider = new PwntoolsLayoutProvider(
{ REA_PWNTOOLS_PYTHON: process.execPath },
launch,
async () => ({
path: root.path,
close: async () => {
if (++removals === 1) throw new Error("transient removal failure");
await root.close();
},
}),
);
onTestFinished(() => provider.close());
const output = await runCli(provider, path);
if (outcome === "decoded") {
expect(output).toMatchObject({
normalized_result: {
artifact: { path },
diagnostics: { stdout: "decoder output", truncated: false },
},
});
expect(process.exitCode ?? 0).toBe(0);
} else {
expect(output).toMatchObject({
code: "execution_failure",
details: {
diagnostics: {
exit_code: 2,
captured_output: { stdout: "decoder output" },
},
},
});
expect(output).not.toHaveProperty("details.cleanup");
expect(process.exitCode).toBe(1);
}
expect(removals).toBe(2);
await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" });
},
);
it.runIf(supportedHost)(
"recovers a completed worker reply after terminal process cleanup succeeds",
async () => {
const { path, root, launch } = await fixture();
let stops = 0;
const provider = new PwntoolsLayoutProvider(
{ REA_PWNTOOLS_PYTHON: process.execPath },
async (input) => {
const launched = await launch(input);
return {
...launched,
cleanup: () =>
++stops < 3
? Promise.resolve({
cleaned: false as const,
reason: "transient process inspection failure",
})
: cleanupOwnedProcessGroup(launched.ownership),
};
},
async () => root,
);
onTestFinished(() => provider.close());
expect(await runCli(provider, path)).toMatchObject({
normalized_result: { artifact: { path } },
});
expect(process.exitCode ?? 0).toBe(0);
expect(stops).toBe(3);
await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" });
},
);
it.runIf(supportedHost)(
"reports only the runtime still owned after process retirement",
async () => {
const { path, root, launch } = await fixture("failed");
let stops = 0;
let removalAllowed = false;
const provider = new PwntoolsLayoutProvider(
{ REA_PWNTOOLS_PYTHON: process.execPath },
async (input) => {
const launched = await launch(input);
return {
...launched,
cleanup: () =>
++stops === 1
? Promise.resolve({
cleaned: false as const,
reason: "transient process inspection failure",
})
: cleanupOwnedProcessGroup(launched.ownership),
};
},
async () => ({
path: root.path,
close: async () => {
if (!removalAllowed) throw new Error("runtime removal denied");
await root.close();
},
}),
);
onTestFinished(async () => {
removalAllowed = true;
await provider.close();
});
expect(await runCli(provider, path)).toMatchObject({
code: "cleanup_incomplete",
details: {
resources: [root.path],
diagnostics: {
primary_error: { code: "execution_failure" },
cleanup_error: {
code: "cleanup_incomplete",
details: { resources: [root.path] },
},
},
},
});
expect(stops).toBe(2);
await expect(access(root.path)).resolves.toBeUndefined();
},
);
it.runIf(supportedHost)(
"preserves the validated report when runtime removal remains denied",
async () => {
const { path, root, launch } = await fixture();
let removalAllowed = false;
const provider = new PwntoolsLayoutProvider(
{ REA_PWNTOOLS_PYTHON: process.execPath },
launch,
async () => ({
path: root.path,
close: async () => {
if (!removalAllowed) throw new Error("runtime removal denied");
await root.close();
},
}),
);
onTestFinished(async () => {
removalAllowed = true;
await provider.close();
});
expect(await runCli(provider, path)).toMatchObject({
code: "cleanup_incomplete",
details: {
resources: [root.path],
partial_observation: {
artifact: { path },
diagnostics: { stdout: "decoder output", truncated: false },
sections: binaryLayoutFixture(path).sections,
},
},
});
expect(process.exitCode).toBe(1);
await expect(access(root.path)).resolves.toBeUndefined();
},
);
it.runIf(supportedHost)(
"preserves cancellation during successful final cleanup",
async () => {
const { path, root, launch } = await fixture();
const controller = new AbortController();
let removals = 0;
const provider = new PwntoolsLayoutProvider(
{ REA_PWNTOOLS_PYTHON: process.execPath },
launch,
async () => ({
path: root.path,
close: async () => {
if (++removals === 1) {
controller.abort();
throw new Error("transient removal failure");
}
await root.close();
},
}),
);
onTestFinished(() => provider.close());
const result = await provider.inspectAndClose(
{ path },
{ signal: controller.signal },
);
if (result.ok)
throw new Error("Cancelled inspection must not publish success");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "cancelled",
details: {
captured_output: { stdout: "decoder output", truncated: false },
},
});
await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" });
},
);