fix(process): report retained capture root

This commit is contained in:
morluto committed 2026-10-27 11:08:55 +08:00
1 parent 768b58c6db
commit 17da88f117
3 files changed
+91 -15

No files matched your search

@@ -1184,7 +1184,9 @@ class ProcessCaptureResourceOwner {
}
get cleanupResources(): readonly string[] {
return unresolvedCleanupResources(this.report);
return unresolvedCleanupResources(this.report).map((resource) =>
resource === "temporary_root" ? this.options.temporaryRoot : resource,
);
}
get report(): ProcessCaptureCleanupReport {
+26 -13
View File
@@ -5,7 +5,16 @@ import {
captureProcessScenario,
retainRootSignaller,
} from "./ProcessHarness.js";
import { settleProcessCaptureJournal } from "./ProcessCaptureLifecycle.js";
import {
settleProcessCaptureJournal,
releaseProcessResources,
ProcessCaptureResourceScope,
createProcessCaptureObservationBuffer,
prepareProcessCapture,
resolveProcessResult,
type ProcessCaptureCleanupOutcome,
type ProcessCaptureCleanupHost,
} from "./ProcessCaptureLifecycle.js";
import { parseProcessCapture } from "../../domain/process/processCaptureParsing.js";
import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../../domain/process/processCaptureExample.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
@@ -19,15 +28,6 @@ import {
normalizeCaptureFailure,
processCaptureCancelled,
} from "./ProcessCaptureError.js";
import {
releaseProcessResources,
ProcessCaptureResourceScope,
createProcessCaptureObservationBuffer,
prepareProcessCapture,
resolveProcessResult,
type ProcessCaptureCleanupOutcome,
type ProcessCaptureCleanupHost,
} from "./ProcessCaptureLifecycle.js";
import { DarwinProcessOwnershipInspectionError } from "../DarwinProcessRunTokenReader.js";
import {
@@ -353,7 +353,10 @@ it("keeps empty cleanup exception messages actionable in the report", async () =
it("retains observations and retries the temporary root when process cleanup throws", async () => {
const cleanupFailure = new Error("termination inspection failed");
const removeTemporaryRoot = vi.fn(async () => undefined);
let rootRemovalFails = true;
const removeTemporaryRoot = vi.fn(async () => {
if (rootRemovalFails) throw new Error("root removal denied");
});
let failing = true;
const scope = new ProcessCaptureResourceScope();
const host: ProcessCaptureCleanupHost = {
@@ -382,6 +385,10 @@ it("retains observations and retries the temporary root when process cleanup thr
state: "failed",
reason: cleanupFailure.message,
});
expect(cleanup.cleanupLease?.cleanupResources).toEqual([
"owned_process_group",
"/fixture/root",
]);
const executionFailure = new Error("capture snapshot failed");
const capture = parseProcessCapture({
...accountFullyObservedProcessCapture({
@@ -401,6 +408,7 @@ it("retains observations and retries the temporary root when process cleanup thr
).toMatchObject({
code: "cleanup_incomplete",
details: {
resources: ["owned_process_group", "/fixture/root"],
cleanup_report: cleanup.report,
execution_failure: "capture snapshot failed",
partial_observation: {
@@ -410,10 +418,15 @@ it("retains observations and retries the temporary root when process cleanup thr
});
}
failing = false;
await expect(scope.retryCleanup()).rejects.toMatchObject({
cleanupResources: ["/fixture/root"],
});
expect(cleanup.cleanupLease?.cleanupResources).toEqual(["/fixture/root"]);
rootRemovalFails = false;
await scope.retryCleanup();
expect(removeTemporaryRoot).toHaveBeenCalledExactlyOnceWith("/fixture/root");
expect(removeTemporaryRoot).toHaveBeenCalledWith("/fixture/root");
await scope.close();
expect(removeTemporaryRoot).toHaveBeenCalledTimes(1);
expect(removeTemporaryRoot).toHaveBeenCalledTimes(2);
});
it("retains unrelated withheld processes through cleanup, capture, and JSON", async () => {
+62 -1
View File
@@ -1,4 +1,4 @@
import { readFile, writeFile } from "node:fs/promises";
import { readFile, readdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -17,6 +17,7 @@ import {
import { createEvidence } from "../../../src/domain/evidence.js";
import { PROCESS_PROVIDER } from "../../../src/domain/process/processEvidenceProvider.js";
import { INVESTIGATION_EXAMPLES } from "../../../src/contracts/investigationExamples.js";
import { observeProcessStartIdentity } from "../../../src/process/ProcessOwnershipObservation.js";
import {
analysisCliErrorEnvelopeSchema,
analysisErrorProjectionSchema,
@@ -76,6 +77,66 @@ describe("documented process CLI workflow", () => {
);
});
describe("process CLI cleanup errors", () => {
cliTest.skipIf(process.platform !== "darwin")(
"reports the retained capture root when platform-binary finalization cannot establish ownership",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-process-cli-cleanup-");
const scenario = join(root, "scenario.json");
const marker = join(root, "target.pid");
await writeFile(
scenario,
JSON.stringify({
executable: "/bin/sh",
arguments: [
"-c",
'printf "%s" "$$" > "$1"; exec /bin/sleep 3',
"capture-cleanup-fixture",
marker,
],
timeout_ms: 500,
idle_timeout_ms: 500,
finalization_ms: 100,
}),
);
const result = await cli.run({
arguments: ["capture-process", scenario, "--json"],
environment: { TMPDIR: root },
});
const targetPid = Number(await readFile(marker, "utf8"));
expect(Number.isSafeInteger(targetPid) && targetPid > 0).toBe(true);
// The fail-closed command deliberately leaves the platform binary alive.
// Observe its bounded natural exit before the temporary fixture is removed.
await expect
.poll(() => observeProcessStartIdentity(targetPid), {
timeout: 7_000,
interval: 25,
})
.toBeUndefined();
expect(result.exitCode, result.stdout || result.stderr).toBe(1);
const retained = (await readdir(root)).filter((name) =>
/^rea-process-(?!token-)/u.test(name),
);
expect(retained).toHaveLength(1);
const retainedRoot = retained[0];
if (retainedRoot === undefined) throw new Error("Missing retained root");
expect(result.json).toMatchObject({
code: "cleanup_incomplete",
message: expect.stringContaining(
"platform_binary_environment_withheld",
),
details: {
resources: ["owned_process_group", join(root, retainedRoot)],
cleanup_report: {
temporary_root: { state: "unverified" },
},
},
});
},
CLI_INTEGRATION_TIMEOUT_MS,
);
});
describe("process CLI errors", () => {
cliTest(
"returns canonical JSON errors for missing and malformed process files",