Files
rea/src/process/ProcessOwnership.test.ts
T
morluto 599a8ee11d fix(process): preserve cleanup ownership and normalize captures once
Retain unresolved process identity and launch evidence through retryable cleanup. Share capture path normalization and preserve failed filesystem observations. Bound child output and drain Windows pipes without one-chunk throttling.

Reviewed fixes include #1628, #2632, #2644, #2784, #2928, #2931, #2965, #2992, and #3011.

Validation: 92 focused source tests passed; four platform-specific skips. Real Windows and Hopper lifecycle coverage was not rerun.
2026-10-14 08:19:07 +08:00

628 lines
19 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import {
cleanupOwnedProcessGroup,
type ProcessOwnershipHost,
} from "./ProcessOwnership.js";
import { parseProcessEnvironment } from "./ProcessOwnershipObservation.js";
import { selectCapturedProcessGroupIds } from "./ProcessOwnershipProcessTree.js";
import { matchesOwnedProcessCommand } from "./ProcessCommandIdentity.js";
import { host, ownership } from "./ProcessOwnership.fixture.js";
describe("owned process-group cleanup discovery", () => {
it("excludes sampled groups whose leader was outside the captured tree", () => {
expect(
selectCapturedProcessGroupIds(100, [
{ pid: 100, process_group_id: 42 },
{ pid: 100, process_group_id: 100 },
{ pid: 101, process_group_id: 42 },
{ pid: 102, process_group_id: 102 },
{ pid: 103, process_group_id: 102 },
]),
).toEqual([100, 102]);
});
it("drops nameless Linux environment entries", () => {
expect(
parseProcessEnvironment("=ignored\0REA_PROCESS_RUN_ID=owned\0EMPTY=\0"),
).toEqual({ REA_PROCESS_RUN_ID: "owned", EMPTY: "" });
});
it("matches macOS executable names after process-table normalization", () => {
expect(
matchesOwnedProcessCommand(
"node /tmp/fake-launcher.mjs",
"/opt/node/bin/node",
"darwin",
),
).toBe(true);
expect(
matchesOwnedProcessCommand("(node)", "/opt/node/bin/node", "darwin"),
).toBe(true);
expect(
matchesOwnedProcessCommand(
"node /tmp/fake-launcher.mjs",
"/opt/python/bin/python3",
"darwin",
),
).toBe(false);
expect(
matchesOwnedProcessCommand(
"node /tmp/fake-launcher.mjs",
"/opt/node/bin/node",
"linux",
),
).toBe(false);
expect(
matchesOwnedProcessCommand("(node)", "/opt/node/bin/node", "linux"),
).toBe(false);
});
it("signals only a group whose every member carries the run token", async () => {
const { adapter, signalGroup } = host({
100: { REA_PROCESS_RUN_ID: "run-token" },
101: { REA_PROCESS_RUN_ID: "run-token" },
});
expect(await cleanupOwnedProcessGroup(ownership, adapter)).toEqual({
cleaned: true,
signaled: true,
});
expect(signalGroup).toHaveBeenCalledWith(100, "SIGKILL");
});
});
describe("opaque process neighbors during cleanup", () => {
it.each([
"platform_binary_environment_withheld",
"restricted_process_environment_withheld",
])(
"cleans verified run-owned groups and records an unrelated %s neighbor",
async (diagnostic) => {
const processes = [
{ pid: 100, parentPid: 1, processGroupId: 100, command: "capture" },
{
pid: 200,
parentPid: 1,
processGroupId: 200,
command: "detached-child",
},
{
pid: 900,
parentPid: 1,
processGroupId: 900,
command: "/usr/bin/opaque-neighbor",
},
].map((process) => ({ ...process, state: "S" }));
const signalGroup = vi.fn();
const identities = new Map(
processes.map(({ pid }) => [
pid,
{ state: "readable" as const, identity: `start-${String(pid)}` },
]),
);
const adapter: ProcessOwnershipHost = {
platform: "darwin",
listProcesses: () => Promise.resolve(processes),
environment: (pid) =>
Promise.resolve({
REA_PROCESS_RUN_ID: pid === 900 ? "unowned" : "run-token",
}),
processIdentities: () => Promise.resolve(identities),
runTokens: (members) =>
Promise.resolve(
new Map(
members.map(({ pid }) => [
pid,
pid === 900
? {
state: "unavailable" as const,
reason: diagnostic,
}
: { state: "readable" as const, runId: "run-token" },
]),
),
),
signalGroup,
};
const result = await cleanupOwnedProcessGroup(
{
...ownership,
sweepTokenOwnedProcesses: true,
captureBaseline: [],
},
adapter,
);
expect(result).toMatchObject({
cleaned: true,
signaled: true,
unverified: [{ pid: 900, diagnostic }],
});
expect(signalGroup.mock.calls).toEqual([
[100, "SIGKILL"],
[200, "SIGKILL"],
]);
expect(signalGroup).not.toHaveBeenCalledWith(900, "SIGKILL");
},
);
it("fails closed for an unrelated candidate with an ordinary unavailable environment", async () => {
const diagnostic = "environment_unavailable";
const processes = [
{ pid: 100, parentPid: 1, processGroupId: 100, command: "capture" },
{
pid: 900,
parentPid: 1,
processGroupId: 900,
command: "opaque-neighbor",
},
].map((process) => ({ ...process, state: "S" }));
const signalGroup = vi.fn();
const identities = new Map(
processes.map(({ pid }) => [
pid,
{ state: "readable" as const, identity: `start-${String(pid)}` },
]),
);
const adapter: ProcessOwnershipHost = {
platform: "darwin",
listProcesses: () => Promise.resolve(processes),
environment: (pid) =>
Promise.resolve({
REA_PROCESS_RUN_ID: pid === 900 ? "unowned" : "run-token",
}),
processIdentities: () => Promise.resolve(identities),
runTokens: (members) =>
Promise.resolve(
new Map(
members.map(({ pid }) => [
pid,
pid === 900
? {
state: "unavailable" as const,
reason: diagnostic,
}
: { state: "readable" as const, runId: "run-token" },
]),
),
),
signalGroup,
};
const result = await cleanupOwnedProcessGroup(
{
...ownership,
sweepTokenOwnedProcesses: true,
captureBaseline: [],
},
adapter,
);
expect(result).toMatchObject({
cleaned: false,
reason: expect.stringContaining(`${diagnostic}=1`),
});
if (result.cleaned)
throw new Error("Expected unknown ownership to fail closed");
expect(result.reason).toContain(`live candidates 900=${diagnostic}`);
expect(result.reason).not.toContain("unowned");
expect(result.reason).not.toContain("run-token");
expect(signalGroup.mock.calls).toEqual([[100, "SIGKILL"]]);
expect(signalGroup).not.toHaveBeenCalledWith(900, "SIGKILL");
});
});
describe("related sweep candidates during cleanup", () => {
it("counts each unreadable live member once", async () => {
const processes = [
{
pid: 100,
parentPid: 1,
processGroupId: 100,
state: "S",
command: "capture",
},
{
pid: 101,
parentPid: 100,
processGroupId: 100,
state: "S",
command: "child-a",
},
{
pid: 102,
parentPid: 100,
processGroupId: 100,
state: "S",
command: "child-b",
},
];
const signalGroup = vi.fn();
const adapter: ProcessOwnershipHost = {
listProcesses: async () => processes,
environment: async () => ({}),
runTokens: async (members) =>
new Map(
members.map(({ pid }) => [
pid,
pid === 100
? { state: "readable" as const, runId: ownership.runId }
: {
state: "unavailable" as const,
reason: "environment_unavailable",
},
]),
),
signalGroup,
};
const result = await cleanupOwnedProcessGroup(
{ ...ownership, sweepTokenOwnedProcesses: true, captureBaseline: [] },
adapter,
);
if (result.cleaned) throw new Error("expected cleanup failure");
expect(signalGroup).not.toHaveBeenCalled();
expect(result.failures?.map(({ pid }) => pid).sort()).toEqual([101, 102]);
expect(result.reason).toContain("2 live process(es)");
});
it("upgrades unreadable evidence when later group validation finds a foreign token", async () => {
const processes = [
{
pid: 100,
parentPid: 1,
processGroupId: 100,
state: "S",
command: "capture",
},
{
pid: 200,
parentPid: 100,
processGroupId: 200,
state: "S",
command: "detached-child",
},
];
const signalGroup = vi.fn();
let childReads = 0;
const adapter: ProcessOwnershipHost = {
listProcesses: async () => processes,
processIdentities: async (members) =>
new Map(
members.map(({ pid }) => [
pid,
{ state: "readable" as const, identity: `start-${String(pid)}` },
]),
),
environment: async () => ({}),
runTokens: async (members) =>
new Map(
members.map(({ pid }) => [
pid,
pid === 100
? { state: "readable" as const, runId: ownership.runId }
: ++childReads <= 4
? {
state: "unavailable" as const,
reason: "environment_unavailable",
}
: { state: "readable" as const, runId: "foreign-token" },
]),
),
signalGroup,
};
const result = await cleanupOwnedProcessGroup(
{ ...ownership, sweepTokenOwnedProcesses: true, captureBaseline: [] },
adapter,
);
expect(result).toMatchObject({
cleaned: false,
reason: "process ownership token mismatch for PID(s): 200",
failures: [{ pid: 200, reason: "run-token-mismatch" }],
});
expect(signalGroup).not.toHaveBeenCalledWith(200, "SIGKILL");
});
});
describe("withheld process evidence during cleanup", () => {
it.each(["revalidation", "signal-error"] as const)(
"retains withheld-process evidence after %s failure",
async (mode) => {
const processes = [
{
pid: 100,
parentPid: 1,
processGroupId: 100,
state: "S",
command: "fixture",
},
{
pid: 900,
parentPid: 1,
processGroupId: 900,
state: "S",
command: "opaque",
},
];
let rootReads = 0;
const signalGroup = vi.fn(() => {
if (mode === "signal-error")
throw Object.assign(new Error("signal denied"), { code: "EACCES" });
});
const adapter: ProcessOwnershipHost = {
listProcesses: async () => processes,
environment: async () => ({}),
runTokens: async (members) =>
new Map(
members.map(({ pid }) => [
pid,
pid === 900
? {
state: "unavailable" as const,
reason: "platform_binary_environment_withheld",
}
: {
state: "readable" as const,
runId:
mode === "revalidation" && ++rootReads > 2
? "other-run"
: "run-token",
},
]),
),
signalGroup,
};
const result = await cleanupOwnedProcessGroup(
{ ...ownership, sweepTokenOwnedProcesses: true, captureBaseline: [] },
adapter,
);
expect(result.unverified).toEqual([
{ pid: 900, diagnostic: "platform_binary_environment_withheld" },
]);
if (result.cleaned) throw new Error("expected cleanup failure");
if (mode === "revalidation")
expect(result.reason).toBe(
"process ownership token mismatch for PID(s): 100",
);
else
expect(result.reason).toBe(
"owned process group signal failed: signal denied",
);
},
);
it.each([
"environment_unavailable",
"platform_binary_environment_withheld",
"restricted_process_environment_withheld",
])("fails closed for a related candidate with %s", async (diagnostic) => {
const processes = [
{
pid: 100,
parentPid: 1,
processGroupId: 100,
state: "S",
command: "capture",
},
{
pid: 101,
parentPid: 100,
processGroupId: 100,
state: "S",
uid:
diagnostic === "restricted_process_environment_withheld"
? (process.getuid?.() ?? 0)
: process.getuid?.() === 0
? 1
: 0,
command: "ready-hang",
},
];
const signalGroup = vi.fn();
const identities = new Map(
processes.map(({ pid }) => [
pid,
{ state: "readable" as const, identity: `start-${String(pid)}` },
]),
);
const adapter: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve(processes),
environment: (pid) =>
pid === 100
? Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" })
: Promise.reject(new Error("EACCES: permission denied")),
processIdentities: () => Promise.resolve(identities),
runTokens: (members) =>
Promise.resolve(
new Map(
members.map(({ pid }) => [
pid,
pid === 100
? { state: "readable" as const, runId: "run-token" }
: {
state: "unavailable" as const,
reason: diagnostic,
},
]),
),
),
signalGroup,
};
const result = await cleanupOwnedProcessGroup(
{
...ownership,
sweepTokenOwnedProcesses: true,
captureBaseline: [],
},
adapter,
);
expect(result).toMatchObject({
cleaned: false,
reason: expect.stringContaining(
"process ownership token could not be read",
),
});
expect(signalGroup).not.toHaveBeenCalled();
});
});
describe("sampled detached process groups during cleanup", () => {
it("signals only token-authenticated sampled groups and reports an unowned sample", async () => {
const processes = [
{
pid: 100,
parentPid: 1,
processGroupId: 100,
state: "S",
command: "capture",
},
{
pid: 200,
parentPid: 1,
processGroupId: 200,
state: "S",
command: "sanitized-detached-child",
},
];
const signalGroup = vi.fn();
const identities = new Map([
[100, { state: "readable" as const, identity: "start-100" }],
[200, { state: "readable" as const, identity: "start-200" }],
]);
const adapter: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve(processes),
environment: (pid) =>
Promise.resolve(pid === 100 ? { REA_PROCESS_RUN_ID: "run-token" } : {}),
processIdentities: () => Promise.resolve(identities),
runTokens: (members) =>
Promise.resolve(
new Map(
members.map(({ pid }) => [
pid,
{
state: "readable" as const,
runId: pid === 100 ? "run-token" : undefined,
},
]),
),
),
signalGroup,
};
const result = await cleanupOwnedProcessGroup(
{
...ownership,
sweepTokenOwnedProcesses: true,
sampledProcessGroupIds: [200],
},
adapter,
);
expect(result).toMatchObject({
cleaned: false,
reason: "process ownership token mismatch for PID(s): 200",
failures: [{ pid: 200, reason: "run-token-mismatch" }],
});
expect(signalGroup).toHaveBeenCalledTimes(1);
expect(signalGroup).toHaveBeenCalledWith(100, "SIGKILL");
expect(signalGroup).not.toHaveBeenCalledWith(200, "SIGKILL");
});
it("reports a live sampled group after the launcher has exited without signaling it", async () => {
const process = {
pid: 200,
parentPid: 1,
processGroupId: 200,
state: "S",
command: "sanitized-detached-child",
};
const signalGroup = vi.fn();
const adapter: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve([process]),
environment: () => Promise.resolve({}),
runTokens: () =>
Promise.resolve(
new Map([[200, { state: "readable" as const, runId: undefined }]]),
),
signalGroup,
};
const result = await cleanupOwnedProcessGroup(
{
...ownership,
sweepTokenOwnedProcesses: true,
sampledProcessGroupIds: [200],
},
adapter,
);
expect(result).toMatchObject({
cleaned: false,
reason: "process ownership token mismatch for PID(s): 200",
failures: [{ pid: 200, reason: "run-token-mismatch" }],
});
expect(signalGroup).not.toHaveBeenCalled();
});
});
describe("rooted process-group cleanup", () => {
it("validates and signals rooted descendant groups once, root first", async () => {
const processes = [
{ pid: 100, parentPid: 1, processGroupId: 100 },
{ pid: 110, parentPid: 100, processGroupId: 100 },
{ pid: 101, parentPid: 100, processGroupId: 101 },
{ pid: 102, parentPid: 101, processGroupId: 102 },
{ pid: 103, parentPid: 102, processGroupId: 102 },
].map((process) => ({
...process,
state: "S",
command: "fixture",
}));
const signalGroup = vi.fn();
const adapter: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve(processes),
environment: () => Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
signalGroup,
};
await expect(cleanupOwnedProcessGroup(ownership, adapter)).resolves.toEqual(
{ cleaned: true, signaled: true },
);
expect(signalGroup.mock.calls).toEqual([
[100, "SIGKILL"],
[101, "SIGKILL"],
[102, "SIGKILL"],
]);
});
it("leaves an unrelated Hopper process group outside the rooted tree untouched", async () => {
const processes = [
{ pid: 100, parentPid: 1, processGroupId: 100, command: "rea-hopper" },
{ pid: 101, parentPid: 100, processGroupId: 101, command: "owned-child" },
{
pid: 900,
parentPid: 1,
processGroupId: 900,
command:
"/Applications/Hopper Disassembler.app/Contents/MacOS/Hopper Disassembler",
},
].map((process) => ({ ...process, state: "S" }));
const environment = vi.fn((pid: number) =>
Promise.resolve({
REA_PROCESS_RUN_ID: pid === 900 ? "unrelated-run" : "run-token",
}),
);
const signalGroup = vi.fn();
const adapter: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve(processes),
environment,
signalGroup,
};
await expect(cleanupOwnedProcessGroup(ownership, adapter)).resolves.toEqual(
{ cleaned: true, signaled: true },
);
expect(signalGroup.mock.calls).toEqual([
[100, "SIGKILL"],
[101, "SIGKILL"],
]);
expect(environment.mock.calls.some(([pid]) => pid === 900)).toBe(false);
});
});