Files
rea/src/process/ProcessOwnershipFacts.test.ts
T
곽현 b2048d46d1 fix(process): retain unresolved descendants during lineage observation (#5270)
* fix(process): retain unresolved descendants during lineage observation

* fix(process): preserve lineage across ancestor generation changes

* fix(process): avoid retaining replacements of exited candidates

* test(cli): avoid unused cache writes in module trace acceptance

* fix(process): retire replaced lineage candidates

* fix(process): preserve uncertain owners and validate launcher scope
2026-10-23 02:25:04 +08:00

767 lines
22 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" | "dead" = "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 token-matching replacement without authority until same-generation validation", async () => {
const host = hostFor([
new Map([[row.pid, identity("start:old")]]),
new Map([[row.pid, identity("start:new")]]),
]);
const facts = await readProcessOwnershipFacts([row], host);
expect(facts[0]).toMatchObject({
state: "replaced",
token: { state: "readable", runId: "run" },
});
const owner = new ProcessOwnershipEvidenceOwner();
const options = {
runId: "run",
listedRows: 1,
listedPids: new Set([row.pid]),
leaderPid: row.pid,
processGroupIds: new Set([row.processGroupId]),
};
expect(owner.reconcile(facts, options)).toMatchObject([
{
pid: row.pid,
identity: undefined,
applicability: "identity-unresolved",
scopeEvidence: "native-identity-unresolved",
relation: undefined,
},
]);
expect(owner.coverage).toMatchObject({
retainedOwned: 0,
retainedUnresolved: 1,
uncertainPids: [row.pid],
});
expect(owner.rowsForObservation([])).toEqual([row]);
const stable = await readProcessOwnershipFacts(
[row],
hostFor([
new Map([[row.pid, identity("start:new")]]),
new Map([[row.pid, identity("start:new")]]),
]),
);
expect(owner.reconcile(stable, options)).toMatchObject([
{ pid: row.pid, identity: "start:new", applicability: "token-verified" },
]);
});
it.each(["token-verified", "identity-unresolved"] as const)(
"retains a token-matching replacement after retiring a %s candidate",
async (applicability) => {
const owner = new ProcessOwnershipEvidenceOwner();
const options = {
runId: "run",
listedRows: 1,
listedPids: new Set([row.pid]),
leaderPid: row.pid,
processGroupIds: new Set([row.processGroupId]),
};
const previousIdentity: ProcessIdentityObservation =
applicability === "token-verified"
? identity("start:old")
: { state: "unavailable", reason: "native identity denied" };
const previous = await readProcessOwnershipFacts(
[row],
hostFor([
new Map([[row.pid, previousIdentity]]),
new Map([[row.pid, previousIdentity]]),
]),
);
expect(owner.reconcile(previous, options)).toMatchObject([
{ pid: row.pid, applicability },
]);
const replacement = await readProcessOwnershipFacts(
[row],
hostFor([
new Map([[row.pid, identity("start:old")]]),
new Map([[row.pid, identity("start:new")]]),
]),
);
expect(owner.reconcile(replacement, options)).toMatchObject([
{
pid: row.pid,
identity: undefined,
applicability: "identity-unresolved",
relation: undefined,
},
]);
expect(owner.rowsForObservation([])).toEqual([row]);
expect(owner.coverage).toMatchObject({
retainedOwned: 0,
retainedUnresolved: 1,
uncertainPids: [row.pid],
});
},
);
it.each(["token-verified", "identity-unresolved"] as const)(
"retires a %s candidate when its replacement has a foreign token",
async (applicability) => {
const owner = new ProcessOwnershipEvidenceOwner();
const options = {
runId: "run",
listedRows: 1,
listedPids: new Set([row.pid]),
leaderPid: row.pid,
processGroupIds: new Set([row.processGroupId]),
};
const previousIdentity: ProcessIdentityObservation =
applicability === "token-verified"
? identity("start:old")
: { state: "unavailable", reason: "native identity denied" };
const previous = await readProcessOwnershipFacts(
[row],
hostFor([
new Map([[row.pid, previousIdentity]]),
new Map([[row.pid, previousIdentity]]),
]),
);
expect(owner.reconcile(previous, options)).toMatchObject([
{ pid: row.pid, applicability },
]);
const replacement = await readProcessOwnershipFacts(
[row],
hostFor(
[
new Map([[row.pid, identity("start:old")]]),
new Map([[row.pid, identity("start:foreign")]]),
],
{ state: "readable", runId: "foreign" },
),
);
expect(owner.reconcile(replacement, options)).toEqual([]);
expect(owner.rowsForObservation([])).toEqual([]);
expect(
new ProcessOwnershipEvidenceOwner().reconcile(replacement, options),
).toEqual([]);
},
);
it.each(["absent", "zombie", "dead"] as const)(
"settles a token-matching replacement when its final native state is %s",
async (state) => {
const owner = new ProcessOwnershipEvidenceOwner();
const options = {
runId: "run",
listedRows: 1,
listedPids: new Set([row.pid]),
leaderPid: row.pid,
processGroupIds: new Set([row.processGroupId]),
};
const previous = await readProcessOwnershipFacts(
[row],
hostFor([
new Map([[row.pid, identity("start:old")]]),
new Map([[row.pid, identity("start:old")]]),
]),
);
owner.reconcile(previous, options);
const terminalIdentity: ProcessIdentityObservation =
state === "absent"
? { state: "absent", reason: "not-in-snapshot" }
: identity("start:new", state);
const replacement = await readProcessOwnershipFacts(
[row],
hostFor([
new Map([[row.pid, identity("start:old")]]),
new Map([[row.pid, terminalIdentity]]),
]),
);
expect(owner.reconcile(replacement, options)).toEqual([]);
expect(owner.rowsForObservation([])).toEqual([]);
expect(
new ProcessOwnershipEvidenceOwner().reconcile(replacement, options),
).toEqual([]);
},
);
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);
});