mirror of
https://github.com/morluto/rea
synced 2026-10-26 22:28:27 -04:00
49e67aa1d9
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
379 lines
12 KiB
TypeScript
379 lines
12 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import type {
|
|
OwnedProcessGroup,
|
|
ProcessLineageObservation,
|
|
ProcessOwnershipHost,
|
|
ProcessTableEntry,
|
|
} from "../process/ProcessOwnership.js";
|
|
import { ProcessOwnershipEvidenceOwner } from "../process/ProcessOwnershipEvidence.js";
|
|
import { cleanupElectronProcesses } from "./ElectronProcessCleanup.js";
|
|
import { observeOwnedProcessLineage } from "../process/ProcessOwnershipObservation.js";
|
|
|
|
describe.skipIf(process.platform === "win32")(
|
|
"Electron cleanup ownership evidence",
|
|
() => {
|
|
it("keeps a previously token-owned helper unresolved after it reparents and changes groups", async () => {
|
|
let rows: readonly ProcessTableEntry[] = [
|
|
{
|
|
pid: 100,
|
|
parentPid: 1,
|
|
processGroupId: 100,
|
|
state: "S",
|
|
command: "electron",
|
|
},
|
|
{
|
|
pid: 101,
|
|
parentPid: 100,
|
|
processGroupId: 100,
|
|
state: "S",
|
|
command: "electron-helper",
|
|
},
|
|
];
|
|
let withholdToken = false;
|
|
const evidenceOwner = new ProcessOwnershipEvidenceOwner();
|
|
const ownership: OwnedProcessGroup = {
|
|
runId: "electron-run",
|
|
leaderPid: 100,
|
|
processGroupId: 100,
|
|
expectedParentPid: 1,
|
|
expectedCommand: "electron",
|
|
sweepTokenOwnedProcesses: true,
|
|
evidenceOwner,
|
|
};
|
|
const host: ProcessOwnershipHost = {
|
|
platform: process.platform,
|
|
listProcesses: () => Promise.resolve(rows),
|
|
environment: () => Promise.resolve({}),
|
|
runTokens: (members) =>
|
|
Promise.resolve(
|
|
new Map(
|
|
members.map(({ pid }) => [
|
|
pid,
|
|
withholdToken
|
|
? {
|
|
state: "unavailable" as const,
|
|
reason: "platform_binary_environment_withheld",
|
|
}
|
|
: { state: "readable" as const, runId: "electron-run" },
|
|
]),
|
|
),
|
|
),
|
|
processIdentities: (members) =>
|
|
Promise.resolve(
|
|
new Map(
|
|
members.map((member) => [
|
|
member.pid,
|
|
withholdToken && member.pid === 100
|
|
? {
|
|
state: "absent" as const,
|
|
reason:
|
|
process.platform === "darwin"
|
|
? ("not-in-snapshot" as const)
|
|
: ("not-found-in-caller-pid-namespace" as const),
|
|
}
|
|
: {
|
|
state: "readable" as const,
|
|
identity: `start-${String(member.pid)}`,
|
|
lifecycle: {
|
|
state: "observed" as const,
|
|
lifecycle: "non-zombie" as const,
|
|
},
|
|
relation: {
|
|
state: "observed" as const,
|
|
parentPid: member.parentPid,
|
|
processGroupId: member.processGroupId,
|
|
},
|
|
},
|
|
]),
|
|
),
|
|
),
|
|
signalGroup: vi.fn(() => {
|
|
rows = [
|
|
{
|
|
pid: 101,
|
|
parentPid: 1,
|
|
processGroupId: 200,
|
|
state: "S",
|
|
command: "electron-helper",
|
|
},
|
|
];
|
|
withholdToken = true;
|
|
}),
|
|
};
|
|
const lineage: ProcessLineageObservation = {
|
|
status: "verified",
|
|
observedAt: "2026-10-19T00:00:00.000Z",
|
|
lineage: {
|
|
runId: ownership.runId,
|
|
launcherPid: ownership.leaderPid,
|
|
launcherParentPid: 1,
|
|
processGroupId: ownership.processGroupId,
|
|
descendants: [{ pid: 101, parentPid: 100, processGroupId: 100 }],
|
|
},
|
|
};
|
|
|
|
const result = await cleanupElectronProcesses(ownership, lineage, {
|
|
platform: process.platform,
|
|
terminateTree: vi.fn(),
|
|
processOwnershipHost: host,
|
|
});
|
|
|
|
expect(result.cleaned).toBe(false);
|
|
if (result.cleaned)
|
|
throw new Error("expected cleanup to remain unresolved");
|
|
expect(result.reason).toContain(
|
|
"process ownership token could not be read",
|
|
);
|
|
expect(evidenceOwner.candidates).toMatchObject([
|
|
{
|
|
pid: 101,
|
|
identity: "start-101",
|
|
applicability: "token-verified",
|
|
scopeEvidence: "run-token",
|
|
relation: { parentPid: 1, processGroupId: 200 },
|
|
},
|
|
]);
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{ peerFails: false, withholdToken: true },
|
|
{ peerFails: true, withholdToken: true },
|
|
{ peerFails: false, withholdToken: false },
|
|
])(
|
|
"retains detached lineage evidence (initial peer failure: $peerFails, withheld token: $withholdToken)",
|
|
async ({ peerFails, withholdToken }) => {
|
|
let detached = false;
|
|
const launcher: ProcessTableEntry = {
|
|
pid: 100,
|
|
parentPid: 1,
|
|
processGroupId: 100,
|
|
state: "S",
|
|
command: "electron",
|
|
};
|
|
const child: ProcessTableEntry = {
|
|
pid: 101,
|
|
parentPid: 100,
|
|
processGroupId: 100,
|
|
state: "S",
|
|
command: "helper",
|
|
};
|
|
const evidenceOwner = new ProcessOwnershipEvidenceOwner();
|
|
const ownership = {
|
|
runId: "lineage-run",
|
|
leaderPid: 100,
|
|
processGroupId: 100,
|
|
sweepTokenOwnedProcesses: true as const,
|
|
evidenceOwner,
|
|
};
|
|
let launcherExited = false;
|
|
let childExited = false;
|
|
const signalGroup = vi.fn((groupId: number) => {
|
|
if (groupId === 100) launcherExited = true;
|
|
if (groupId === 200) childExited = true;
|
|
});
|
|
|
|
const host: ProcessOwnershipHost = {
|
|
listProcesses: async () => [
|
|
...(launcherExited ? [] : [launcher]),
|
|
...(childExited
|
|
? []
|
|
: [
|
|
detached
|
|
? { ...child, parentPid: 1, processGroupId: 200 }
|
|
: child,
|
|
]),
|
|
...(!detached && peerFails ? [{ ...child, pid: 102 }] : []),
|
|
],
|
|
environment: async () => ({}),
|
|
runTokens: async (members) =>
|
|
new Map(
|
|
members.map(({ pid }) => [
|
|
pid,
|
|
(detached && withholdToken && pid === 101) ||
|
|
(peerFails && pid === 102)
|
|
? {
|
|
state: "unavailable" as const,
|
|
reason: "platform_binary_environment_withheld",
|
|
}
|
|
: { state: "readable" as const, runId: ownership.runId },
|
|
]),
|
|
),
|
|
processIdentities: async (members) =>
|
|
new Map(
|
|
members.map(({ pid }) => [
|
|
pid,
|
|
(launcherExited && pid === 100) ||
|
|
(childExited && pid === 101) ||
|
|
(detached && pid === 102)
|
|
? { state: "absent" as const, reason: "not-in-snapshot" as const }
|
|
: {
|
|
state: "readable" as const,
|
|
identity: `generation-${pid}`,
|
|
lifecycle: {
|
|
state: "observed" as const,
|
|
lifecycle: "non-zombie" as const,
|
|
},
|
|
relation: {
|
|
state: "observed" as const,
|
|
parentPid: pid === 100 || detached ? 1 : 100,
|
|
processGroupId: detached && pid === 101 ? 200 : 100,
|
|
},
|
|
},
|
|
]),
|
|
),
|
|
signalGroup,
|
|
};
|
|
const first = await observeOwnedProcessLineage(ownership, host);
|
|
expect(first.status).toBe(peerFails ? "unavailable" : "verified");
|
|
expect(evidenceOwner.candidates).toContainEqual(
|
|
expect.objectContaining({
|
|
pid: 101,
|
|
identity: "generation-101",
|
|
applicability: "token-verified",
|
|
}),
|
|
);
|
|
detached = true;
|
|
const current = await observeOwnedProcessLineage(ownership, host);
|
|
expect(current).toMatchObject({
|
|
status: "verified",
|
|
lineage: { descendants: [] },
|
|
});
|
|
const result = await cleanupElectronProcesses(ownership, current, {
|
|
platform: "darwin",
|
|
terminateTree: vi.fn(),
|
|
processOwnershipHost: host,
|
|
});
|
|
if (withholdToken) {
|
|
expect(result).toMatchObject({ cleaned: false });
|
|
expect(signalGroup).not.toHaveBeenCalledWith(200, expect.anything());
|
|
expect(evidenceOwner.candidates).toContainEqual(
|
|
expect.objectContaining({
|
|
pid: 101,
|
|
identity: "generation-101",
|
|
applicability: "token-verified",
|
|
}),
|
|
);
|
|
} else {
|
|
expect(result).toMatchObject({ cleaned: true, signaled: true });
|
|
expect(signalGroup.mock.calls).toEqual([
|
|
[100, "SIGKILL"],
|
|
[200, "SIGKILL"],
|
|
]);
|
|
expect(evidenceOwner.candidates).toEqual([]);
|
|
}
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"signals every verified captured group before the shared final census",
|
|
async () => {
|
|
let rows: readonly ProcessTableEntry[] = [
|
|
{
|
|
pid: 100,
|
|
parentPid: 1,
|
|
processGroupId: 100,
|
|
state: "S",
|
|
command: "electron",
|
|
},
|
|
{
|
|
pid: 101,
|
|
parentPid: 100,
|
|
processGroupId: 100,
|
|
state: "S",
|
|
command: "renderer",
|
|
},
|
|
{
|
|
pid: 200,
|
|
parentPid: 101,
|
|
processGroupId: 200,
|
|
state: "S",
|
|
command: "helper-group-leader",
|
|
},
|
|
{
|
|
pid: 201,
|
|
parentPid: 200,
|
|
processGroupId: 200,
|
|
state: "S",
|
|
command: "helper",
|
|
},
|
|
];
|
|
const signalGroups: number[] = [];
|
|
const ownership = {
|
|
runId: "multi-group-run",
|
|
leaderPid: 100,
|
|
processGroupId: 100,
|
|
expectedParentPid: 1,
|
|
sweepTokenOwnedProcesses: true as const,
|
|
evidenceOwner: new ProcessOwnershipEvidenceOwner(),
|
|
};
|
|
const host: ProcessOwnershipHost = {
|
|
platform: process.platform,
|
|
listProcesses: async () => rows,
|
|
environment: async () => ({}),
|
|
runTokens: async (members) =>
|
|
new Map(
|
|
members.map(({ pid }) => [
|
|
pid,
|
|
{ state: "readable" as const, runId: ownership.runId },
|
|
]),
|
|
),
|
|
processIdentities: async (members) => {
|
|
const liveByPid = new Map(rows.map((row) => [row.pid, row]));
|
|
return new Map(
|
|
members.map((member) => {
|
|
const current = liveByPid.get(member.pid);
|
|
return [
|
|
member.pid,
|
|
current === undefined
|
|
? {
|
|
state: "absent" as const,
|
|
reason: "not-in-snapshot" as const,
|
|
}
|
|
: {
|
|
state: "readable" as const,
|
|
identity: `generation-${String(member.pid)}`,
|
|
relation: {
|
|
state: "observed" as const,
|
|
parentPid: current.parentPid,
|
|
processGroupId: current.processGroupId,
|
|
},
|
|
},
|
|
] as const;
|
|
}),
|
|
);
|
|
},
|
|
signalGroup: (groupId) => {
|
|
signalGroups.push(groupId);
|
|
rows = rows.filter(({ processGroupId }) => processGroupId !== groupId);
|
|
},
|
|
};
|
|
const lineage: ProcessLineageObservation = {
|
|
status: "verified",
|
|
observedAt: "2026-10-19T00:00:00.000Z",
|
|
lineage: {
|
|
runId: ownership.runId,
|
|
launcherPid: ownership.leaderPid,
|
|
launcherParentPid: 1,
|
|
processGroupId: ownership.processGroupId,
|
|
descendants: [
|
|
{ pid: 101, parentPid: 100, processGroupId: 100 },
|
|
{ pid: 200, parentPid: 101, processGroupId: 200 },
|
|
{ pid: 201, parentPid: 200, processGroupId: 200 },
|
|
],
|
|
},
|
|
};
|
|
|
|
const result = await cleanupElectronProcesses(ownership, lineage, {
|
|
platform: process.platform,
|
|
terminateTree: vi.fn(),
|
|
processOwnershipHost: host,
|
|
});
|
|
|
|
expect(result).toMatchObject({ cleaned: true, signaled: true });
|
|
expect(signalGroups).toEqual([100, 200]);
|
|
expect(rows).toEqual([]);
|
|
},
|
|
);
|