Files
rea/tests/process-global/processPrelaunchCancellation.test.ts
morluto 49e67aa1d9 fix(process): retain native ownership evidence through cleanup
Keep a run-scoped evidence owner from acquisition through settlement and
cleanup retry. Bind token observations to native generations and retain
related generations and unresolved identity lookups across table omissions,
reparenting and process-group changes without treating PID hints as authority.

Use native effective UID facts for withholding decisions, corroborate Linux
procfs absence, and share the Linux stat parser with sampling and debugger
identity checks. Signal only after current token and group revalidation.
Complete swept cleanup across all groups with one bounded final family check;
retain unresolved owners and successful-peer completion across retries.

Validation: npm run check, docs:check and test discovery passed. Focused
ownership/consumer coverage totals 513 passed and 1 skipped across 35 files,
including the corrected Electron cancellation test rerun independently. Darwin helper compilation, real
owned Node child cleanup, CLI/MCP capture and retained cleanup retries ran.
Real Electron applications, native Linux races/PID reuse and Windows cleanup
were not qualified. The original Linux reports remain open for that work.

Fixes #5238
Refs #4322, #5206
2026-10-19 20:04:00 +08:00

151 lines
5.0 KiB
TypeScript

import { rm, stat, type FileHandle } from "node:fs/promises";
import { afterEach, expect, it, vi } from "vitest";
import { captureProcessScenario } from "../../src/process/capture/ProcessHarness.js";
import { parseProcessScenario } from "../../src/domain/process/processScenario.js";
import { emptyProcessCapture } from "../../src/domain/process/processCapture.fixture.js";
const control = vi.hoisted(() => ({
stage: "none" as "none" | "before" | "open" | "final-stat" | "close",
controller: undefined as AbortController | undefined,
reason: undefined as Error | undefined,
handle: undefined as FileHandle | undefined,
sampled: false,
closed: false,
launches: 0,
}));
// Exercise the actual startup decision without creating a target process.
vi.mock("@lydell/node-pty", () => ({
spawn: () => {
control.launches += 1;
throw new Error("PTY launch sentinel");
},
}));
// Native ownership inspection is outside this portable launch-boundary test.
vi.mock("../../src/process/ProcessOwnershipObservation.js", async (load) => {
const original =
await load<
typeof import("../../src/process/ProcessOwnershipObservation.js")
>();
return {
...original,
prepareProcessOwnershipInspection: async () => {},
systemProcessOwnershipHost: {
...original.systemProcessOwnershipHost,
captureBaseline: async () => [],
},
};
});
vi.mock("node:fs/promises", async (load) => {
const original = await load<typeof import("node:fs/promises")>();
return {
...original,
open: async (...args: Parameters<typeof original.open>) => {
const handle = await original.open(...args);
if (args[0] !== process.execPath) return handle;
control.handle = handle;
const nativeStat = handle.stat.bind(handle);
const nativeClose = handle.close.bind(handle);
const nativeRead = handle.read.bind(handle);
handle.read = (async (
buffer: Buffer,
offset: number,
length: number,
position: number | bigint | null,
) => {
const result = await nativeRead(buffer, offset, length, position);
if (result.bytesRead === 0) control.sampled = true;
return result;
}) as typeof handle.read;
handle.stat = (async (...parameters: Parameters<typeof handle.stat>) => {
const metadata = await nativeStat(...parameters);
if (control.sampled && control.stage === "final-stat")
control.controller?.abort(control.reason);
return metadata;
}) as typeof handle.stat;
handle.close = async () => {
await nativeClose();
control.closed = true;
if (control.stage === "close")
control.controller?.abort(control.reason);
};
if (control.stage === "open") control.controller?.abort(control.reason);
return handle;
},
};
});
afterEach(() => {
control.controller = undefined;
control.reason = undefined;
control.handle = undefined;
});
it.each(["none", "before", "open", "final-stat", "close"] as const)(
"gates launch on cancellation at %s while releasing startup resources",
async (stage) => {
const controller = new AbortController();
const reason = new Error(`cancel at ${stage}`);
Object.assign(control, {
stage,
controller,
reason,
sampled: false,
closed: false,
launches: 0,
});
if (stage === "before") controller.abort(reason);
const removed: string[] = [];
const result = await captureProcessScenario(
parseProcessScenario({
executable: process.execPath,
arguments: ["--eval", "process.exit(0)"],
working_directory: process.cwd(),
settle_ms: 0,
}),
controller.signal,
"linux",
{},
async () => ({
files: [],
truncated: false,
completeRoots: [],
coverage: emptyProcessCapture().truncation_details.filesystem_before,
}),
{
platform: "linux",
cleanupProcessGroup: async () => {
throw new Error("No process should have been acquired");
},
removeTemporaryRoot: async (path) => {
await rm(path, { recursive: true });
removed.push(path);
},
},
);
expect(control.launches).toBe(stage === "none" ? 1 : 0);
if (result.ok) throw new Error("Expected sentinel failure or cancellation");
if (stage === "none")
expect(String(result.error.cause)).toContain("PTY launch sentinel");
else {
expect(result.error).toMatchObject({ reason: "cancelled" });
expect(result.error.cause).toBe(reason);
}
if (stage === "before") {
expect(control.handle).toBeUndefined();
expect(removed).toEqual([]);
} else {
expect(control.closed).toBe(true);
expect(control.handle?.fd).toBe(-1);
expect(removed).toHaveLength(1);
const removedRoot = removed[0];
if (removedRoot === undefined) throw new Error("Expected removed root");
await expect(stat(removedRoot)).rejects.toMatchObject({ code: "ENOENT" });
expect(control.sampled).toBe(stage !== "open");
}
},
);