diff --git a/src/process/ProcessOwnershipObservation.ts b/src/process/ProcessOwnershipObservation.ts index a0beeae736..4f9a1ca624 100644 --- a/src/process/ProcessOwnershipObservation.ts +++ b/src/process/ProcessOwnershipObservation.ts @@ -82,7 +82,6 @@ export const observeOwnedProcessGroups = async ( host, signal, ); - const uncertainGroups = new Set(); 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( - 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 || diff --git a/src/process/capture/ProcessCaptureOwnershipEvidence.test.ts b/src/process/capture/ProcessCaptureOwnershipEvidence.test.ts index 27ee931952..cf2528a0f5 100644 --- a/src/process/capture/ProcessCaptureOwnershipEvidence.test.ts +++ b/src/process/capture/ProcessCaptureOwnershipEvidence.test.ts @@ -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((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(); + }, +);