mirror of
https://github.com/morluto/rea.git
synced 2026-11-02 21:06:04 +00:00
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
601 lines
17 KiB
TypeScript
601 lines
17 KiB
TypeScript
import { expect, it, vi } from "vitest";
|
|
|
|
import type {
|
|
ProcessIdentityObservation,
|
|
ProcessOwnershipFact,
|
|
ProcessOwnershipHost,
|
|
ProcessTableEntry,
|
|
ProcessRunTokenObservation,
|
|
ProcessNativeRelationObservation,
|
|
} from "./ProcessOwnership.js";
|
|
import { readProcessOwnershipFacts } from "./ProcessOwnershipObservation.js";
|
|
import { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js";
|
|
|
|
const row: ProcessTableEntry = {
|
|
pid: 42,
|
|
parentPid: 1,
|
|
processGroupId: 42,
|
|
state: "S",
|
|
command: "fixture",
|
|
};
|
|
|
|
const identity = (
|
|
value: string,
|
|
lifecycle: "non-zombie" | "zombie" = "non-zombie",
|
|
relation: ProcessNativeRelationObservation = {
|
|
state: "observed",
|
|
parentPid: row.parentPid,
|
|
processGroupId: row.processGroupId,
|
|
},
|
|
): ProcessIdentityObservation => ({
|
|
state: "readable",
|
|
identity: value,
|
|
lifecycle: { state: "observed", lifecycle },
|
|
relation,
|
|
});
|
|
|
|
const hostFor = (
|
|
identities: readonly ReadonlyMap<number, ProcessIdentityObservation>[],
|
|
token: ProcessRunTokenObservation = { state: "readable", runId: "run" },
|
|
): ProcessOwnershipHost => {
|
|
let identityRead = 0;
|
|
return {
|
|
platform: "linux",
|
|
listProcesses: vi.fn(async () => {
|
|
throw new Error(
|
|
"ownership fact owner must not enumerate the process table",
|
|
);
|
|
}),
|
|
environment: async () => ({}),
|
|
processIdentities: async () => {
|
|
const result = identities[identityRead];
|
|
identityRead += 1;
|
|
return result ?? new Map();
|
|
},
|
|
runTokens: async () => new Map([[row.pid, token]]),
|
|
signalGroup: () => undefined,
|
|
};
|
|
};
|
|
|
|
it("binds owned, missing, and foreign tokens only to the same identity generation", async () => {
|
|
for (const token of [
|
|
{ state: "readable", runId: "run" },
|
|
{ state: "readable", runId: undefined },
|
|
{ state: "readable", runId: "foreign" },
|
|
] as const) {
|
|
const host = hostFor(
|
|
[
|
|
new Map([[row.pid, identity("start:42")]]),
|
|
new Map([[row.pid, identity("start:42")]]),
|
|
],
|
|
token,
|
|
);
|
|
const [fact] = await readProcessOwnershipFacts([row], host);
|
|
expect(fact).toMatchObject({
|
|
state: "same-generation",
|
|
token,
|
|
identityBefore: { state: "readable", identity: "start:42" },
|
|
identityAfter: { state: "readable", identity: "start:42" },
|
|
});
|
|
}
|
|
});
|
|
|
|
it("retains a matching token as partial evidence when identity changes", async () => {
|
|
const host = hostFor([
|
|
new Map([[row.pid, identity("start:old")]]),
|
|
new Map([[row.pid, identity("start:new")]]),
|
|
]);
|
|
const [fact] = await readProcessOwnershipFacts([row], host);
|
|
expect(fact).toMatchObject({
|
|
state: "replaced",
|
|
token: { state: "readable", runId: "run" },
|
|
});
|
|
});
|
|
|
|
it("keeps omitted identities uncertain and native absence distinct", async () => {
|
|
const omitted = hostFor([new Map(), new Map()]);
|
|
const [uncertain] = await readProcessOwnershipFacts([row], omitted);
|
|
expect(uncertain).toMatchObject({
|
|
state: "unavailable",
|
|
identityBefore: {
|
|
state: "unavailable",
|
|
reason: "process identity was not returned during before observation",
|
|
},
|
|
});
|
|
|
|
const absent: ProcessIdentityObservation = {
|
|
state: "absent",
|
|
reason: "not-in-snapshot",
|
|
};
|
|
const absentHost = hostFor([
|
|
new Map([[row.pid, absent]]),
|
|
new Map([[row.pid, absent]]),
|
|
]);
|
|
const [gone] = await readProcessOwnershipFacts([row], absentHost);
|
|
expect(gone).toMatchObject({
|
|
state: "absent",
|
|
token: { state: "readable", runId: "run" },
|
|
});
|
|
expect(absentHost.listProcesses).not.toHaveBeenCalled();
|
|
|
|
const appeared = hostFor([
|
|
new Map([[row.pid, absent]]),
|
|
new Map([[row.pid, identity("start:appeared")]]),
|
|
]);
|
|
const [replacement] = await readProcessOwnershipFacts([row], appeared);
|
|
expect(replacement?.state).toBe("replaced");
|
|
});
|
|
|
|
it("keeps zombie lifecycle separate from identity and token diagnostics intact", async () => {
|
|
const zombie = hostFor(
|
|
[
|
|
new Map([[row.pid, identity("start:zombie", "zombie")]]),
|
|
new Map([[row.pid, identity("start:zombie", "zombie")]]),
|
|
],
|
|
{ state: "unavailable", reason: "environment denied" },
|
|
);
|
|
const [fact] = await readProcessOwnershipFacts([row], zombie);
|
|
expect(fact).toMatchObject({
|
|
state: "same-generation",
|
|
token: { state: "unavailable", reason: "environment denied" },
|
|
});
|
|
expect(fact?.identityBefore).toEqual(
|
|
expect.objectContaining({
|
|
identity: "start:zombie",
|
|
lifecycle: { state: "observed", lifecycle: "zombie" },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("keeps stale table ancestry separate from current native membership", async () => {
|
|
const staleRow: ProcessTableEntry = {
|
|
...row,
|
|
pid: 101,
|
|
parentPid: 100,
|
|
processGroupId: 100,
|
|
};
|
|
const newGenerationRelation = {
|
|
state: "observed",
|
|
parentPid: 1,
|
|
processGroupId: 102,
|
|
} as const;
|
|
const host = hostFor([
|
|
new Map([
|
|
[
|
|
staleRow.pid,
|
|
identity("start:new", "non-zombie", newGenerationRelation),
|
|
],
|
|
]),
|
|
new Map([
|
|
[
|
|
staleRow.pid,
|
|
identity("start:new", "non-zombie", newGenerationRelation),
|
|
],
|
|
]),
|
|
]);
|
|
|
|
const [fact] = await readProcessOwnershipFacts([staleRow], host);
|
|
|
|
expect(fact).toMatchObject({
|
|
state: "same-generation",
|
|
process: { pid: 101, parentPid: 100, processGroupId: 100 },
|
|
identityBefore: { relation: newGenerationRelation },
|
|
identityAfter: { relation: newGenerationRelation },
|
|
});
|
|
expect(fact?.identityAfter).toMatchObject({
|
|
relation: { parentPid: 1, processGroupId: 102 },
|
|
});
|
|
});
|
|
|
|
it("retains both native relations when group membership changes during the read", async () => {
|
|
const staleRow: ProcessTableEntry = {
|
|
...row,
|
|
pid: 101,
|
|
parentPid: 100,
|
|
processGroupId: 100,
|
|
};
|
|
const beforeRelation = {
|
|
state: "observed",
|
|
parentPid: 100,
|
|
processGroupId: 100,
|
|
} as const;
|
|
const afterRelation = {
|
|
state: "observed",
|
|
parentPid: 1,
|
|
processGroupId: 102,
|
|
} as const;
|
|
const host = hostFor([
|
|
new Map([
|
|
[staleRow.pid, identity("start:same", "non-zombie", beforeRelation)],
|
|
]),
|
|
new Map([
|
|
[staleRow.pid, identity("start:same", "non-zombie", afterRelation)],
|
|
]),
|
|
]);
|
|
|
|
const [fact] = await readProcessOwnershipFacts([staleRow], host);
|
|
|
|
expect(fact?.state).toBe("same-generation");
|
|
expect(fact?.process).toEqual(staleRow);
|
|
expect(fact?.identityBefore).toMatchObject({ relation: beforeRelation });
|
|
expect(fact?.identityAfter).toMatchObject({ relation: afterRelation });
|
|
});
|
|
|
|
it("retains owned generations across retries after their token becomes unreadable", () => {
|
|
const owner = new ProcessOwnershipEvidenceOwner();
|
|
const owned: ProcessOwnershipFact = {
|
|
process: row,
|
|
state: "same-generation",
|
|
identityBefore: identity("start:owned"),
|
|
token: { state: "readable", runId: "run" },
|
|
identityAfter: identity("start:owned"),
|
|
};
|
|
owner.reconcile([owned], {
|
|
runId: "run",
|
|
listedRows: 1,
|
|
listedPids: new Set([row.pid]),
|
|
leaderPid: 1,
|
|
processGroupIds: new Set([row.processGroupId]),
|
|
});
|
|
|
|
const later = {
|
|
...owned,
|
|
token: { state: "unavailable", reason: "environment denied" },
|
|
identityBefore: identity("start:owned"),
|
|
identityAfter: identity("start:owned", "non-zombie", {
|
|
state: "observed",
|
|
parentPid: 1,
|
|
processGroupId: 55,
|
|
}),
|
|
} satisfies ProcessOwnershipFact;
|
|
const retained = owner.reconcile([later], {
|
|
runId: "run",
|
|
listedRows: 0,
|
|
listedPids: new Set(),
|
|
leaderPid: 1,
|
|
processGroupIds: new Set([row.processGroupId]),
|
|
});
|
|
|
|
expect(retained).toMatchObject([
|
|
{
|
|
pid: row.pid,
|
|
identity: "start:owned",
|
|
applicability: "token-verified",
|
|
relation: { parentPid: 1, processGroupId: 55 },
|
|
},
|
|
]);
|
|
expect(owner.coverage).toMatchObject({
|
|
listedRows: 0,
|
|
retainedOnlyPids: 1,
|
|
retainedOwned: 1,
|
|
processTableCoverage: "unreported-by-host",
|
|
});
|
|
});
|
|
|
|
it("upgrades an identity-unresolved candidate only after a same-generation token match", () => {
|
|
const owner = new ProcessOwnershipEvidenceOwner();
|
|
const options = {
|
|
runId: "run",
|
|
listedRows: 1,
|
|
listedPids: new Set([row.pid]),
|
|
leaderPid: 42,
|
|
processGroupIds: new Set([row.processGroupId]),
|
|
};
|
|
const unavailable: ProcessIdentityObservation = {
|
|
state: "unavailable",
|
|
reason: "native identity denied",
|
|
};
|
|
const first: ProcessOwnershipFact = {
|
|
process: row,
|
|
state: "unavailable",
|
|
identityBefore: unavailable,
|
|
token: { state: "unavailable", reason: "EPERM" },
|
|
identityAfter: unavailable,
|
|
};
|
|
expect(owner.reconcile([first], options)).toMatchObject([
|
|
{
|
|
pid: row.pid,
|
|
identity: undefined,
|
|
applicability: "identity-unresolved",
|
|
},
|
|
]);
|
|
|
|
const current: ProcessOwnershipFact = {
|
|
process: row,
|
|
state: "same-generation",
|
|
identityBefore: identity("start:current"),
|
|
token: { state: "readable", runId: "run" },
|
|
identityAfter: identity("start:current"),
|
|
};
|
|
expect(owner.reconcile([current], options)).toMatchObject([
|
|
{
|
|
pid: row.pid,
|
|
identity: "start:current",
|
|
applicability: "token-verified",
|
|
scopeEvidence: "run-token",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("lets a successful native absence snapshot retire a token-matching old generation", () => {
|
|
const owner = new ProcessOwnershipEvidenceOwner();
|
|
const owned: ProcessOwnershipFact = {
|
|
process: row,
|
|
state: "same-generation",
|
|
identityBefore: identity("start:owned"),
|
|
token: { state: "readable", runId: "run" },
|
|
identityAfter: identity("start:owned"),
|
|
};
|
|
const options = {
|
|
runId: "run",
|
|
listedRows: 1,
|
|
listedPids: new Set([row.pid]),
|
|
leaderPid: 1,
|
|
processGroupIds: new Set([row.processGroupId]),
|
|
};
|
|
owner.reconcile([owned], options);
|
|
|
|
const snapshotAbsence: ProcessIdentityObservation = {
|
|
state: "absent",
|
|
reason: "not-in-snapshot",
|
|
};
|
|
const contradictory = {
|
|
...owned,
|
|
state: "absent" as const,
|
|
identityBefore: snapshotAbsence,
|
|
identityAfter: snapshotAbsence,
|
|
} satisfies ProcessOwnershipFact;
|
|
|
|
expect(owner.reconcile([contradictory], options)).toEqual([]);
|
|
expect(owner.coverage.retainedOwned).toBe(0);
|
|
});
|
|
|
|
it("retains a newly verified replacement generation after retiring the old one", () => {
|
|
const owner = new ProcessOwnershipEvidenceOwner();
|
|
const options = {
|
|
runId: "run",
|
|
listedRows: 1,
|
|
listedPids: new Set([row.pid]),
|
|
leaderPid: 1,
|
|
processGroupIds: new Set([row.processGroupId]),
|
|
};
|
|
owner.reconcile(
|
|
[
|
|
{
|
|
process: row,
|
|
state: "same-generation",
|
|
identityBefore: identity("start:old"),
|
|
token: { state: "readable", runId: "run" },
|
|
identityAfter: identity("start:old"),
|
|
},
|
|
],
|
|
options,
|
|
);
|
|
|
|
const replacement: ProcessOwnershipFact = {
|
|
process: row,
|
|
state: "same-generation",
|
|
identityBefore: identity("start:new"),
|
|
token: { state: "readable", runId: "run" },
|
|
identityAfter: identity("start:new"),
|
|
};
|
|
expect(owner.reconcile([replacement], options)).toMatchObject([
|
|
{
|
|
pid: row.pid,
|
|
identity: "start:new",
|
|
applicability: "token-verified",
|
|
scopeEvidence: "run-token",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("retains only related unresolved rows and retires them on replacement", () => {
|
|
const owner = new ProcessOwnershipEvidenceOwner();
|
|
const candidate: ProcessTableEntry = {
|
|
...row,
|
|
pid: 77,
|
|
parentPid: 42,
|
|
processGroupId: 55,
|
|
};
|
|
const unreadable: ProcessOwnershipFact = {
|
|
process: candidate,
|
|
state: "same-generation",
|
|
identityBefore: identity("start:maybe-owned", "non-zombie", {
|
|
state: "observed",
|
|
parentPid: 42,
|
|
processGroupId: 55,
|
|
}),
|
|
token: { state: "unavailable", reason: "permission denied" },
|
|
identityAfter: identity("start:maybe-owned", "non-zombie", {
|
|
state: "observed",
|
|
parentPid: 42,
|
|
processGroupId: 55,
|
|
}),
|
|
};
|
|
const options = {
|
|
runId: "run",
|
|
listedRows: 1,
|
|
listedPids: new Set([77]),
|
|
leaderPid: 42,
|
|
processGroupIds: new Set<number>([42]),
|
|
};
|
|
expect(owner.reconcile([unreadable], options)).toMatchObject([
|
|
{
|
|
pid: 77,
|
|
applicability: "related-ownership-unresolved",
|
|
scopeEvidence: "capture-leader-pid",
|
|
},
|
|
]);
|
|
|
|
const reparented = {
|
|
...unreadable,
|
|
process: { ...candidate, parentPid: 1, processGroupId: 100 },
|
|
identityBefore: identity("start:maybe-owned", "non-zombie", {
|
|
state: "observed",
|
|
parentPid: 1,
|
|
processGroupId: 100,
|
|
}),
|
|
identityAfter: identity("start:maybe-owned", "non-zombie", {
|
|
state: "observed",
|
|
parentPid: 1,
|
|
processGroupId: 100,
|
|
}),
|
|
} satisfies ProcessOwnershipFact;
|
|
expect(
|
|
owner.reconcile([reparented], {
|
|
...options,
|
|
listedRows: 0,
|
|
listedPids: new Set(),
|
|
}),
|
|
).toMatchObject([
|
|
{
|
|
pid: 77,
|
|
applicability: "related-ownership-unresolved",
|
|
scopeEvidence: "previously-related-generation",
|
|
relation: { processGroupId: 100 },
|
|
},
|
|
]);
|
|
|
|
const replacement = {
|
|
...reparented,
|
|
state: "replaced" as const,
|
|
identityAfter: identity("start:replacement"),
|
|
};
|
|
expect(owner.reconcile([replacement], options)).toEqual([]);
|
|
});
|
|
|
|
it("requires a retained owned parent to be present as the same native generation", () => {
|
|
const owner = new ProcessOwnershipEvidenceOwner();
|
|
const parent: ProcessTableEntry = {
|
|
...row,
|
|
pid: 70,
|
|
parentPid: 1,
|
|
processGroupId: 70,
|
|
};
|
|
const child: ProcessTableEntry = {
|
|
...row,
|
|
pid: 71,
|
|
parentPid: 70,
|
|
processGroupId: 71,
|
|
};
|
|
const parentFact: ProcessOwnershipFact = {
|
|
process: parent,
|
|
state: "same-generation",
|
|
identityBefore: identity("start:parent", "non-zombie", {
|
|
state: "observed",
|
|
parentPid: 1,
|
|
processGroupId: 70,
|
|
}),
|
|
token: { state: "readable", runId: "run" },
|
|
identityAfter: identity("start:parent", "non-zombie", {
|
|
state: "observed",
|
|
parentPid: 1,
|
|
processGroupId: 70,
|
|
}),
|
|
};
|
|
const options = {
|
|
runId: "run",
|
|
listedRows: 1,
|
|
listedPids: new Set([parent.pid]),
|
|
leaderPid: 42,
|
|
processGroupIds: new Set<number>([42]),
|
|
};
|
|
owner.reconcile([parentFact], options);
|
|
|
|
const unreadableChild: ProcessOwnershipFact = {
|
|
process: child,
|
|
state: "same-generation",
|
|
identityBefore: identity("start:child", "non-zombie", {
|
|
state: "observed",
|
|
parentPid: 70,
|
|
processGroupId: 71,
|
|
}),
|
|
token: { state: "unavailable", reason: "permission denied" },
|
|
identityAfter: identity("start:child", "non-zombie", {
|
|
state: "observed",
|
|
parentPid: parent.pid,
|
|
processGroupId: child.processGroupId,
|
|
}),
|
|
};
|
|
expect(
|
|
owner.reconcile([unreadableChild], {
|
|
...options,
|
|
listedRows: 1,
|
|
listedPids: new Set([child.pid]),
|
|
}),
|
|
).toMatchObject([{ pid: parent.pid, applicability: "token-verified" }]);
|
|
|
|
const currentParent = {
|
|
...parentFact,
|
|
token: { state: "unavailable", reason: "permission denied" },
|
|
} satisfies ProcessOwnershipFact;
|
|
expect(
|
|
owner.reconcile([currentParent, unreadableChild], {
|
|
...options,
|
|
listedRows: 2,
|
|
listedPids: new Set([parent.pid, child.pid]),
|
|
}),
|
|
).toMatchObject([
|
|
{ pid: parent.pid, applicability: "token-verified" },
|
|
{
|
|
pid: child.pid,
|
|
applicability: "related-ownership-unresolved",
|
|
scopeEvidence: "current-token-owned-parent",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("does not retain unrelated transient rows and reports table coverage as unknown", () => {
|
|
const owner = new ProcessOwnershipEvidenceOwner();
|
|
const unrelated: ProcessOwnershipFact = {
|
|
process: { ...row, pid: 88, parentPid: 1, processGroupId: 88 },
|
|
state: "same-generation",
|
|
identityBefore: identity("start:unrelated", "non-zombie", {
|
|
state: "observed",
|
|
parentPid: 1,
|
|
processGroupId: 88,
|
|
}),
|
|
token: { state: "unavailable", reason: "temporary permission error" },
|
|
identityAfter: identity("start:unrelated", "non-zombie", {
|
|
state: "observed",
|
|
parentPid: 1,
|
|
processGroupId: 88,
|
|
}),
|
|
};
|
|
expect(
|
|
owner.reconcile([unrelated], {
|
|
runId: "run",
|
|
listedRows: 1,
|
|
listedPids: new Set([88]),
|
|
leaderPid: 42,
|
|
processGroupIds: new Set([42]),
|
|
}),
|
|
).toEqual([]);
|
|
expect(owner.coverage).toMatchObject({
|
|
listedRows: 1,
|
|
examinedPids: 1,
|
|
retainedUnresolved: 0,
|
|
processTableCoverage: "unreported-by-host",
|
|
});
|
|
});
|
|
|
|
it("preserves the caller's cancellation reason after the final identity read", async () => {
|
|
const controller = new AbortController();
|
|
const reason = new Error("caller stopped final identity read");
|
|
let identityRead = 0;
|
|
const host: ProcessOwnershipHost = {
|
|
...hostFor([
|
|
new Map([[row.pid, identity("start:42")]]),
|
|
new Map([[row.pid, identity("start:42")]]),
|
|
]),
|
|
processIdentities: async () => {
|
|
identityRead += 1;
|
|
if (identityRead === 2) controller.abort(reason);
|
|
return new Map([[row.pid, identity("start:42")]]);
|
|
},
|
|
};
|
|
await expect(
|
|
readProcessOwnershipFacts([row], host, controller.signal),
|
|
).rejects.toBe(reason);
|
|
});
|