fix(process): derive group settlement from completed native facts (#10043)

fix(process): settle native-confirmed absent group members
This commit is contained in:
morluto authored and GitHub committed 2026-11-11 17:15:02 +08:00
1 parent 55c746afaf
commit dd522bf9a7
2 files changed
+105 -19

No files matched your search

+13 -19
View File
@@ -82,7 +82,6 @@ export const observeOwnedProcessGroups = async (
host,
signal,
);
const uncertainGroups = new Set<number>();
const candidateProcesses = observationRows.filter((process) => {
if (retainedPids.has(process.pid)) return true;
const identity = firstIdentities.get(process.pid);
@@ -93,15 +92,8 @@ export const observeOwnedProcessGroups = async (
groupIds.includes(identity.relation.processGroupId)
)
return true;
if (
groupIds.includes(process.processGroupId) &&
identity.relation?.state !== "observed"
)
uncertainGroups.add(process.processGroupId);
return groupIds.includes(process.processGroupId);
}
if (groupIds.includes(process.processGroupId))
uncertainGroups.add(process.processGroupId);
return groupIds.includes(process.processGroupId);
});
const facts = await readProcessOwnershipFacts(
@@ -119,24 +111,26 @@ export const observeOwnedProcessGroups = async (
scopeFacts: facts,
});
const observations = new Map<number, ProcessGroupObservation>(
groupIds.map((groupId) => [
groupId,
uncertainGroups.has(groupId)
? {
state: "unverifiable",
reason: "native process-group membership could not be verified",
}
: { state: "empty" },
]),
groupIds.map((groupId) => [groupId, { state: "empty" }]),
);
for (const fact of facts) {
if (fact.identityAfter.state === "absent") continue;
if (processIdentityHasExited(fact.identityAfter)) continue;
// An initial unreadable membership remains uncertain only while the
// final native fact does not establish that this candidate has exited.
if (
groupIds.includes(fact.process.processGroupId) &&
(fact.identityBefore.state !== "readable" ||
fact.identityBefore.relation?.state !== "observed")
)
observations.set(fact.process.processGroupId, {
state: "unverifiable",
reason: "native process-group membership could not be verified",
});
const nativeGroupId =
fact.identityAfter.state === "readable" &&
fact.identityAfter.relation?.state === "observed"
? fact.identityAfter.relation.processGroupId
: undefined;
if (processIdentityHasExited(fact.identityAfter)) continue;
const candidateGroups = groupIds.filter(
(groupId) =>
nativeGroupId === groupId ||
@@ -1,3 +1,4 @@
import { spawn } from "node:child_process";
import { describe, expect, it, vi } from "vitest";
import type {
ProcessIdentityObservation,
@@ -151,3 +152,94 @@ describe("capture ownership evidence lifetime", () => {
);
});
});
it.each([
"absent",
"absent-after-unavailable",
"zombie",
"unavailable",
] as const)(
"classifies completed inert-child settlement with native %s facts",
async (native) => {
const child = spawn(process.execPath, ["-e", "process.exit(0)"], {
detached: process.platform !== "win32",
stdio: "ignore",
});
await new Promise<void>((resolve, reject) => {
child.once("error", reject);
child.once("exit", (code) =>
code === 0
? resolve()
: reject(new Error(`Inert child exit ${String(code)}`)),
);
});
if (child.pid === undefined) throw new Error("Missing inert child PID");
// The child exited for real; the host supplies a stale table row and
// controlled native facts. This qualifies settlement, not the native reader.
const row = {
pid: child.pid,
parentPid: process.pid,
processGroupId: child.pid,
state: "S",
command: "inert fixture",
};
const observation: ProcessIdentityObservation =
native === "absent" || native === "absent-after-unavailable"
? { state: "absent", reason: "not-in-snapshot" }
: native === "zombie"
? {
state: "readable",
identity: "fixture-start",
lifecycle: { state: "observed", lifecycle: "zombie" },
relation: {
state: "observed",
parentPid: process.pid,
processGroupId: child.pid,
},
}
: { state: "unavailable", reason: "native identity unavailable" };
let identityReads = 0;
const signalGroup = vi.fn();
const host: ProcessOwnershipHost = {
listProcesses: async () => [row],
processIdentities: async (rows) => {
identityReads += 1;
return new Map(
rows.map(({ pid }) => [
pid,
native === "absent-after-unavailable" && identityReads === 1
? {
state: "unavailable" as const,
reason: "initial native read unavailable",
}
: observation,
]),
);
},
runTokens: async (rows) =>
new Map(
rows.map(({ pid }) => [
pid,
{
state: "unavailable" as const,
reason: "completed child has no token",
},
]),
),
environment: async () => ({}),
signalGroup,
};
const settled = await observeSettlement({
runId: "inert-run",
processGroupIds: [child.pid],
ownershipEvidence: new ProcessOwnershipEvidenceOwner(),
settleMs: 100,
platform: "darwin",
ownershipHost: host,
});
expect(settled.state).toBe(
native === "unavailable" ? "unverifiable" : "quiesced",
);
expect(signalGroup).not.toHaveBeenCalled();
},
);