diff --git a/docs/process-capture.md b/docs/process-capture.md index 9096b494fa..67f81c085f 100644 --- a/docs/process-capture.md +++ b/docs/process-capture.md @@ -461,7 +461,33 @@ selected roots; it is not a per-file allowance. File contents are read in Ownership inspections bind run-token reads to native start identities and retain related generations through reparenting, group changes, omitted rows, and cleanup -retries. The captured run keeps one evidence owner through settlement and cleanup. +retries. A host without native identity reads still token-checks process-table +rows that appear only in a launcher-exit refresh before reporting that group +clean or publishing an empty lineage. A matching token authorizes a signal only +for a group whose leader carries the same token. A matching token whose leader +is absent, or an unreadable token for a still-live new row, stays unresolved. A +readable foreign token is not signaled. An unreadable row that has exited by +the confirmation read does not hide a new row in that confirmation table, and a +group proven while revalidating another owned group is signaled in the same +cleanup. A matched row that changes parent or group on the confirmation read +is token-checked again. A PID whose token was already read is read again +on the next confirmation even when its parent and group return to the +pre-exit values. A PID whose first token was foreign is read again +on that table, because the PID alone does not identify the process. A PID +from the pre-exit table is read again when its parent or group changes, and +an unchanged pre-existing row that was never token-read stays unread. Lineage +publishes one row for each PID. Confirmation reads are bounded; a table that keeps producing unreadable +replacements stays unresolved. That refresh does not retain a ledger +for a later attempt that never observes the launcher. Lineage reads a row again +when its parent, group, or launcher-tree membership changes, and it reads a +token again on the next refresh even when those fields are unchanged. The +launcher is read on every lineage refresh; a missing, unowned, or +relation-changed launcher PID is not published as a verified lineage. A +descendant absent from the latest refresh is omitted. An +ancestor move cannot leave a foreign descendant inside a verified result. A +group that reappears after cleanup already processed it is revalidated once; +another appearance stays unresolved. The captured run keeps +one evidence owner through settlement and cleanup. An absent or changed token leaves a previously related generation unresolved; current token proof is still required before signaling. Swept cleanup verifies the remaining family after its group signals. Coverage is bounded by the native diff --git a/docs/testing.md b/docs/testing.md index 5ff3c4712e..e0b3f39a77 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -272,7 +272,23 @@ termination. Simulated Windows controls do not establish native Windows behavior Native process-ownership controls distinguish start identity, zombie lifecycle, current parent/group relation, and token-read evidence. Retain generation-bound candidates through smaller lineage observations, table omissions, reparenting, -group changes, and failed cleanup retries. A readable token with unavailable +group changes, and failed cleanup retries. Identityless exit-refresh controls +cover a replacement row that is not linked to the prior descendant set: a +matching token on its own group is signaled, a foreign or absent token is not, +an unreadable live token stays unresolved, a replacement that appears only in +the confirmation table is still token-checked, a group discovered during +revalidation is signaled before cleanup succeeds, a member that changes groups +during confirmation is signaled in its new group, a PID first seen with a +foreign token is read again on the confirmation table, a PID whose token was +already read is read again even when its parent and group return to the +pre-exit values, a pre-existing PID is read again when its parent or group +changes, an unchanged pre-existing row that was never token-read stays unread, +an unbounded confirmation +chain stays unresolved, lineage reads a row again when its launcher-tree +membership changes, a cached lineage token is read again on the next refresh, an initial descendant whose token already matched is read again on a later refresh, the launcher is read again on that refresh and a missing, unowned, or relation-changed launcher PID stays unavailable, a descendant absent from the latest refresh is omitted, +a group that reappears after it was processed is revalidated once, a further +reappearance stays unresolved, lineage publishes one row per PID, and lineage does not publish a +verified empty descendant list while that row remains unexamined. A readable token with unavailable identity stays unresolved. Native UID facts, rather than an earlier process-table row, determine whether a token denial concerns another user. Darwin tests compile the helper and exercise snapshot growth, absence, lifecycle, and UID observations; diff --git a/src/process/ProcessOwnership.part3.test.ts b/src/process/ProcessOwnership.part3.test.ts index bab43d7a97..875d87a0ed 100644 --- a/src/process/ProcessOwnership.part3.test.ts +++ b/src/process/ProcessOwnership.part3.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, vi } from "vitest"; -import type { ProcessOwnershipHost } from "./ProcessOwnership.js"; +import type { + ProcessOwnershipHost, + ProcessTableEntry, +} from "./ProcessOwnership.js"; import { observeOwnedProcessLineage } from "./ProcessOwnershipObservation.js"; const ownership = { runId: "run-token", @@ -106,7 +109,595 @@ describe("owned process-group cleanup discovery", () => { }, }); }); +}); +describe("identityless lineage refresh keeps replacement rows", () => { + it("does not publish an empty lineage when exit refresh reveals a token-owned replacement", async () => { + const launcher = { + pid: 100, + parentPid: 1, + processGroupId: 100, + state: "S", + command: "root", + }; + const child = { + pid: 101, + parentPid: 100, + processGroupId: 100, + state: "S", + command: "child", + }; + const replacement = { + pid: 102, + parentPid: 1, + processGroupId: 102, + state: "S", + command: "reparented-grandchild", + }; + const environment = vi.fn((pid: number) => { + if (pid === 101) + return Promise.reject( + new Error("process exited during environment read"), + ); + return Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }); + }); + const listProcesses = vi + .fn() + .mockResolvedValueOnce([launcher, child]) + .mockResolvedValue([launcher, replacement]); + await expect( + observeOwnedProcessLineage(ownership, { + listProcesses, + environment, + signalGroup: vi.fn(), + }), + ).resolves.toMatchObject({ + status: "unavailable", + reason: + "process lineage refresh contained owned PID(s) outside the observed descendants: 102", + }); + expect(environment).toHaveBeenCalledWith(102); + }); + + it("publishes a descendant that appears in the exit refresh and still belongs to the launcher", async () => { + const launcher = { + pid: 100, + parentPid: 1, + processGroupId: 100, + state: "S", + command: "root", + }; + const child = { + pid: 101, + parentPid: 100, + processGroupId: 100, + state: "S", + command: "child", + }; + const grandchild = { + pid: 102, + parentPid: 100, + processGroupId: 102, + state: "S", + command: "grandchild", + }; + const listProcesses = vi + .fn() + .mockResolvedValueOnce([launcher, child]) + .mockResolvedValue([launcher, grandchild]); + await expect( + observeOwnedProcessLineage(ownership, { + listProcesses, + environment: (pid) => + pid === 101 + ? Promise.reject( + new Error("process exited during environment read"), + ) + : Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }), + signalGroup: vi.fn(), + }), + ).resolves.toMatchObject({ + status: "verified", + lineage: { + descendants: [{ pid: 102, parentPid: 100, processGroupId: 102 }], + }, + }); + }); +}); + +describe("identityless lineage refresh ignores the prior process table", () => { + const row = ( + pid: number, + parentPid: number, + processGroupId = pid, + ): ProcessTableEntry => ({ + pid, + parentPid, + processGroupId, + state: "S", + command: `pid-${String(pid)}`, + }); + + it("does not token-read a pre-existing row when a descendant exits", async () => { + const environment = vi.fn((pid: number) => + pid === 101 + ? Promise.reject(new Error("process exited during environment read")) + : pid === 50 + ? Promise.reject(new Error("unreadable pre-existing process")) + : Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }), + ); + const listProcesses = vi + .fn() + .mockResolvedValueOnce([row(100, 1, 100), row(101, 100, 100), row(50, 1)]) + .mockResolvedValue([row(100, 1, 100), row(50, 1)]); + await expect( + observeOwnedProcessLineage(ownership, { + listProcesses, + environment, + signalGroup: vi.fn(), + }), + ).resolves.toMatchObject({ + status: "verified", + lineage: { descendants: [] }, + }); + expect(environment.mock.calls.map(([pid]) => pid)).not.toContain(50); + }); + + it("re-reads a refresh row after it moves under the launcher", async () => { + let replacementReads = 0; + const environment = vi.fn((pid: number) => { + if (pid === 101 || pid === 103) + return Promise.reject( + new Error("process exited during environment read"), + ); + if (pid !== 102) + return Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }); + replacementReads += 1; + return Promise.resolve( + replacementReads === 1 ? {} : { REA_PROCESS_RUN_ID: "run-token" }, + ); + }); + const outside = row(102, 1, 102); + const joined = row(102, 100, 102); + const listProcesses = vi + .fn() + .mockResolvedValueOnce([row(100, 1, 100), row(101, 100), row(103, 100)]) + .mockResolvedValueOnce([row(100, 1, 100), row(103, 100), outside]) + .mockResolvedValue([row(100, 1, 100), joined]); + await expect( + observeOwnedProcessLineage(ownership, { + listProcesses, + environment, + signalGroup: vi.fn(), + }), + ).resolves.toMatchObject({ + status: "verified", + lineage: { + descendants: [{ pid: 102, parentPid: 100, processGroupId: 102 }], + }, + }); + expect(replacementReads).toBe(2); + }); +}); + +describe("identityless lineage refresh follows tree membership", () => { + const row = ( + pid: number, + parentPid: number, + processGroupId = pid, + ): ProcessTableEntry => ({ + pid, + parentPid, + processGroupId, + state: "S", + command: `pid-${String(pid)}`, + }); + + it("token-checks an initial outsider after it joins the launcher", async () => { + const environment = vi.fn((pid: number) => + pid === 101 + ? Promise.reject(new Error("process exited during environment read")) + : Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }), + ); + const listProcesses = vi + .fn() + .mockResolvedValueOnce([row(100, 1, 100), row(101, 100), row(102, 1)]) + .mockResolvedValue([row(100, 1, 100), row(102, 100)]); + await expect( + observeOwnedProcessLineage(ownership, { + listProcesses, + environment, + signalGroup: vi.fn(), + }), + ).resolves.toMatchObject({ + status: "verified", + lineage: { + descendants: [{ pid: 102, parentPid: 100, processGroupId: 102 }], + }, + }); + expect(environment).toHaveBeenCalledWith(102); + }); + + it("rejects a cached foreign row after an ancestor joins the launcher", async () => { + let outsiderReads = 0; + const environment = vi.fn((pid: number) => { + if (pid === 101 || pid === 103) + return Promise.reject( + new Error("process exited during environment read"), + ); + if (pid !== 102) + return Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }); + outsiderReads += 1; + return Promise.resolve({}); + }); + const listProcesses = vi + .fn() + .mockResolvedValueOnce([ + row(100, 1, 100), + row(101, 100), + row(103, 100), + row(50, 1), + ]) + .mockResolvedValueOnce([ + row(100, 1, 100), + row(103, 100), + row(50, 1), + row(102, 50), + ]) + .mockResolvedValue([row(100, 1, 100), row(50, 100), row(102, 50)]); + await expect( + observeOwnedProcessLineage(ownership, { + listProcesses, + environment, + signalGroup: vi.fn(), + }), + ).resolves.toMatchObject({ + status: "unavailable", + reason: "process lineage contains an unowned or PID-reused process", + }); + expect(outsiderReads).toBe(2); + expect(environment.mock.calls.filter(([pid]) => pid === 50)).toHaveLength( + 1, + ); + }); + + it("re-reads a cached descendant when its relation is unchanged", async () => { + let replacementReads = 0; + const environment = vi.fn((pid: number) => { + if (pid === 101 || pid === 103) + return Promise.reject( + new Error("process exited during environment read"), + ); + if (pid !== 102) + return Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }); + replacementReads += 1; + return Promise.resolve( + replacementReads === 1 ? { REA_PROCESS_RUN_ID: "run-token" } : {}, + ); + }); + const descendant = row(102, 100, 102); + const listProcesses = vi + .fn() + .mockResolvedValueOnce([row(100, 1, 100), row(101, 100), row(103, 100)]) + .mockResolvedValueOnce([row(100, 1, 100), row(103, 100), descendant]) + .mockResolvedValue([row(100, 1, 100), descendant]); + await expect( + observeOwnedProcessLineage(ownership, { + listProcesses, + environment, + signalGroup: vi.fn(), + }), + ).resolves.toMatchObject({ + status: "unavailable", + reason: "process lineage contains an unowned or PID-reused process", + }); + expect(replacementReads).toBe(2); + }); +}); + +describe("identityless lineage refresh drops exited descendants", () => { + const row = ( + pid: number, + parentPid: number, + processGroupId = pid, + ): ProcessTableEntry => ({ + pid, + parentPid, + processGroupId, + state: "S", + command: `pid-${String(pid)}`, + }); + + it("omits a validated descendant that is gone from the latest refresh", async () => { + const environment = vi.fn((pid: number) => + pid === 101 || pid === 103 + ? Promise.reject(new Error("process exited during environment read")) + : Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }), + ); + const listProcesses = vi + .fn() + .mockResolvedValueOnce([ + row(100, 1, 100), + row(102, 100), + row(101, 100), + row(103, 100), + ]) + .mockResolvedValueOnce([row(100, 1, 100), row(102, 100), row(103, 100)]) + .mockResolvedValue([row(100, 1, 100)]); + const result = await observeOwnedProcessLineage(ownership, { + listProcesses, + environment, + signalGroup: vi.fn(), + }); + expect(result.status).toBe("verified"); + if (result.status !== "verified") return; + expect(result.lineage.descendants).toEqual([]); + }); +}); + +describe("identityless lineage refresh rechecks the launcher relation", () => { + const row = ( + pid: number, + parentPid: number, + processGroupId = pid, + command = `pid-${String(pid)}`, + ): ProcessTableEntry => ({ + pid, + parentPid, + processGroupId, + state: "S", + command, + }); + + it("rejects a same-token launcher whose process group changed", async () => { + const listProcesses = vi + .fn() + .mockResolvedValueOnce([ + row(100, 1, 100, "root"), + row(102, 100), + row(101, 100), + ]) + .mockResolvedValue([row(100, 1, 999, "root"), row(102, 100)]); + await expect( + observeOwnedProcessLineage( + { ...ownership, expectedCommand: "root" }, + { + listProcesses, + environment: (pid) => + pid === 101 + ? Promise.reject( + new Error("process exited during environment read"), + ) + : Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }), + signalGroup: vi.fn(), + }, + ), + ).resolves.toMatchObject({ + status: "unavailable", + reason: "owned launcher process-group identity did not match", + }); + }); + + it("rejects a same-token launcher whose command changed", async () => { + const listProcesses = vi + .fn() + .mockResolvedValueOnce([ + row(100, 1, 100, "root"), + row(102, 100), + row(101, 100), + ]) + .mockResolvedValue([row(100, 1, 100, "replacement"), row(102, 100)]); + await expect( + observeOwnedProcessLineage( + { ...ownership, expectedCommand: "root" }, + { + listProcesses, + environment: (pid) => + pid === 101 + ? Promise.reject( + new Error("process exited during environment read"), + ) + : Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }), + signalGroup: vi.fn(), + }, + ), + ).resolves.toMatchObject({ + status: "unavailable", + reason: + "owned launcher command identity did not match (observed=replacement; expected=root)", + }); + }); + + it("rejects a same-token launcher whose parent changed", async () => { + const listProcesses = vi + .fn() + .mockResolvedValueOnce([ + row(100, 1, 100, "root"), + row(102, 100), + row(101, 100), + ]) + .mockResolvedValue([row(100, 7, 100, "root"), row(102, 100)]); + await expect( + observeOwnedProcessLineage( + { ...ownership, expectedParentPid: 1, expectedCommand: "root" }, + { + listProcesses, + environment: (pid) => + pid === 101 + ? Promise.reject( + new Error("process exited during environment read"), + ) + : Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }), + signalGroup: vi.fn(), + }, + ), + ).resolves.toMatchObject({ + status: "unavailable", + reason: "owned launcher parent identity did not match", + }); + }); +}); + +describe("identityless lineage refresh rechecks the launcher", () => { + const row = ( + pid: number, + parentPid: number, + processGroupId = pid, + ): ProcessTableEntry => ({ + pid, + parentPid, + processGroupId, + state: "S", + command: `pid-${String(pid)}`, + }); + + it("does not publish lineage when the launcher PID is gone on a later refresh", async () => { + const listProcesses = vi + .fn() + .mockResolvedValueOnce([row(100, 1, 100), row(102, 100), row(101, 100)]) + .mockResolvedValue([row(102, 100)]); + await expect( + observeOwnedProcessLineage(ownership, { + listProcesses, + environment: (pid) => + pid === 101 + ? Promise.reject( + new Error("process exited during environment read"), + ) + : Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }), + signalGroup: vi.fn(), + }), + ).resolves.toMatchObject({ + status: "unavailable", + reason: "owned launcher exited during lineage validation", + }); + }); + + it("rejects a refresh whose launcher PID is no longer owned", async () => { + let launcherReads = 0; + const environment = vi.fn((pid: number) => { + if (pid === 101) + return Promise.reject( + new Error("process exited during environment read"), + ); + if (pid !== 100) + return Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }); + launcherReads += 1; + return Promise.resolve( + launcherReads === 1 ? { REA_PROCESS_RUN_ID: "run-token" } : {}, + ); + }); + const listProcesses = vi + .fn() + .mockResolvedValueOnce([row(100, 1, 100), row(102, 100), row(101, 100)]) + .mockResolvedValue([row(100, 1, 100), row(102, 100)]); + await expect( + observeOwnedProcessLineage(ownership, { + listProcesses, + environment, + signalGroup: vi.fn(), + }), + ).resolves.toMatchObject({ + status: "unavailable", + reason: "process lineage contains an unowned or PID-reused process", + }); + expect(launcherReads).toBe(2); + }); +}); + +describe("identityless lineage refresh rechecks an initial descendant", () => { + const row = ( + pid: number, + parentPid: number, + processGroupId = pid, + ): ProcessTableEntry => ({ + pid, + parentPid, + processGroupId, + state: "S", + command: `pid-${String(pid)}`, + }); + + it("rejects a reused PID after an earlier descendant token matched", async () => { + let reads = 0; + const environment = vi.fn((pid: number) => { + if (pid === 101) + return Promise.reject( + new Error("process exited during environment read"), + ); + if (pid !== 102) + return Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }); + reads += 1; + return Promise.resolve( + reads === 1 ? { REA_PROCESS_RUN_ID: "run-token" } : {}, + ); + }); + const descendant = row(102, 100, 102); + const listProcesses = vi + .fn() + .mockResolvedValueOnce([row(100, 1, 100), descendant, row(101, 100)]) + .mockResolvedValue([row(100, 1, 100), descendant]); + await expect( + observeOwnedProcessLineage(ownership, { + listProcesses, + environment, + signalGroup: vi.fn(), + }), + ).resolves.toMatchObject({ + status: "unavailable", + reason: "process lineage contains an unowned or PID-reused process", + }); + expect(reads).toBe(2); + }); +}); + +describe("identityless lineage refresh publishes one row per PID", () => { + const row = ( + pid: number, + parentPid: number, + processGroupId = pid, + ): ProcessTableEntry => ({ + pid, + parentPid, + processGroupId, + state: "S", + command: `pid-${String(pid)}`, + }); + + it("keeps a single row when a descendant returns to its original group", async () => { + const environment = vi.fn((pid: number) => + pid === 101 || pid === 104 + ? Promise.reject(new Error("process exited during environment read")) + : Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }), + ); + const listProcesses = vi + .fn() + .mockResolvedValueOnce([ + row(100, 1, 100), + row(101, 100), + row(102, 100, 100), + row(104, 100), + ]) + .mockResolvedValueOnce([ + row(100, 1, 100), + row(102, 100, 102), + row(104, 100), + ]) + .mockResolvedValue([row(100, 1, 100), row(102, 100, 100)]); + const result = await observeOwnedProcessLineage(ownership, { + listProcesses, + environment, + signalGroup: vi.fn(), + }); + expect(result.status).toBe("verified"); + if (result.status !== "verified") return; + expect(result.lineage.descendants).toEqual([ + { pid: 102, parentPid: 100, processGroupId: 100 }, + ]); + }); +}); + +describe("owned process-group cleanup discovery", () => { it("does not publish lineage when the launcher exits during validation", async () => { const launcher = { pid: 100, diff --git a/src/process/ProcessOwnership.ts b/src/process/ProcessOwnership.ts index c8a74a83af..5ca6d021aa 100644 --- a/src/process/ProcessOwnership.ts +++ b/src/process/ProcessOwnership.ts @@ -348,7 +348,23 @@ export const cleanupOwnedProcessGroup = async ( ); if ("cleaned" in plan) return plan; let signaled = false; - for (const processGroupId of plan.signalOrder) { + const pendingGroups = [...plan.signalOrder]; + const pending = new Set(pendingGroups); + const passes = new Map(); + while (pendingGroups.length > 0) { + const processGroupId = pendingGroups.shift(); + if (processGroupId === undefined) break; + pending.delete(processGroupId); + const seen = passes.get(processGroupId) ?? 0; + if (seen >= OWNED_GROUP_REVALIDATION_LIMIT) + return mergeUnverifiedObservations( + { + cleaned: false, + reason: `owned process group ${String(processGroupId)} reappeared after revalidation`, + }, + plan.unverified, + ); + passes.set(processGroupId, seen + 1); const revalidation = await revalidateOwnedProcessGroup( processGroupId, ownership, @@ -358,6 +374,19 @@ export const cleanupOwnedProcessGroup = async ( ); if ("cleaned" in revalidation) return mergeUnverifiedObservations(revalidation, plan.unverified); + for (const groupId of revalidation.escapedProcessGroupIds) { + if (groupId === processGroupId || pending.has(groupId)) continue; + if ((passes.get(groupId) ?? 0) >= OWNED_GROUP_REVALIDATION_LIMIT) + return mergeUnverifiedObservations( + { + cleaned: false, + reason: `owned process group ${String(groupId)} reappeared after revalidation`, + }, + plan.unverified, + ); + pending.add(groupId); + pendingGroups.push(groupId); + } if (revalidation.empty) continue; try { host.signalGroup(processGroupId, "SIGKILL"); @@ -497,6 +526,12 @@ interface OwnedCleanupPlan { readonly unverified: readonly OwnershipSweepUnverifiedProcess[]; } +/** Process table retained after launcher-exit settlement, plus groups proven by token. */ +interface SettledProcessTable { + readonly processes: readonly ProcessTableEntry[]; + readonly escapedProcessGroupIds: readonly number[]; +} + interface ProcessRelationSnapshot { readonly currentRelations: readonly ProcessTableEntry[]; readonly identities: ReadonlyMap; @@ -561,6 +596,169 @@ const readProcessRelationSnapshot = async ( return { currentRelations, identities, failures }; }; +const settledProcessTable = ( + processes: readonly ProcessTableEntry[], +): SettledProcessTable => ({ + processes, + escapedProcessGroupIds: [], +}); + +/** Confirmation tables taken after an unreadable exit-refresh row disappears. */ +const EXIT_REFRESH_CONFIRMATION_LIMIT = 8; + +/** Parent and group recorded for a PID in the pre-exit process table. */ +interface InitialProcessRelation { + readonly parentPid: number; + readonly processGroupId: number; +} + +/** + * Newcomers are token-checked. A PID from the pre-exit table is checked only + * when its parent or group changed, which is the identityless sign that the + * PID may have been reused. An unchanged pre-existing row stays unread. + */ +const isExitRefreshCandidate = ( + process: ProcessTableEntry, + ownership: OwnedProcessGroup, + initialRelations: ReadonlyMap, +): boolean => { + if (process.processGroupId === ownership.processGroupId) return false; + const initial = initialRelations.get(process.pid); + return ( + initial === undefined || + initial.parentPid !== process.parentPid || + initial.processGroupId !== process.processGroupId + ); +}; + +/** + * Times one group may be revalidated in a single cleanup. The second pass + * covers a group that reappears after it was already processed. Another + * appearance stays unresolved instead of reporting success. + */ +const OWNED_GROUP_REVALIDATION_LIMIT = 2; + +/** + * Token-check rows that exist only in the post-exit table. A prior PID, group, + * or ancestry link is handled by the caller; these rows have none of those. + * Foreign tokens are not owned. A matching token authorizes only a group whose + * leader also carries that token. An unreadable live row stays unresolved. + */ +const classifyUnlinkedExitRows = async ( + ownership: OwnedProcessGroup, + refreshed: readonly ProcessTableEntry[], + unlinked: readonly ProcessTableEntry[], + host: ProcessOwnershipHost, + initialRelations: ReadonlyMap, +): Promise< + | { readonly kind: "clear"; readonly processes: readonly ProcessTableEntry[] } + | { readonly kind: "uncertain"; readonly reason: string } + | { + readonly kind: "escaped"; + readonly processes: readonly ProcessTableEntry[]; + readonly groupIds: readonly number[]; + } +> => { + let current = refreshed; + let pending = unlinked; + let confirmations = 0; + const matched: ProcessTableEntry[] = []; + const inspected = new Set(); + // A readable token does not expire when the PID's parent and group return + // to the pre-exit values. Those PIDs are read again on the next table. + const tokenRead = new Set(); + while (pending.length > 0) { + const due = pending.filter((process) => !inspected.has(process.pid)); + if (due.length === 0) break; + for (const process of due) inspected.add(process.pid); + const unreadable: { readonly pid: number; readonly reason: string }[] = []; + for await (const { process, observation } of readProcessRunTokens( + host, + due, + )) { + if (observation.state === "readable") { + tokenRead.add(process.pid); + if (observation.runId === ownership.runId) matched.push(process); + continue; + } + unreadable.push({ pid: process.pid, reason: observation.reason }); + } + if (unreadable.length === 0) break; + let liveRows: readonly ProcessTableEntry[] | undefined; + try { + liveRows = liveProcesses(await host.listProcesses()); + } catch (cause: unknown) { + void cause; + liveRows = undefined; + } + const live = + liveRows === undefined + ? undefined + : new Set(liveRows.map(({ pid }) => pid)); + const stillLive = unreadable.filter( + (candidate) => live === undefined || live.has(candidate.pid), + ); + if (stillLive.length > 0) { + const only = stillLive.length === 1 ? stillLive[0] : undefined; + const pids = stillLive + .map(({ pid }) => pid) + .sort((left, right) => left - right); + return { + kind: "uncertain", + reason: + only === undefined + ? `process ownership could not be revalidated for PID(s): ${pids.join(", ")}` + : `process ownership could not be revalidated for PID ${String(only.pid)}: ${only.reason}`, + }; + } + if (liveRows === undefined) break; + confirmations += 1; + if (confirmations > EXIT_REFRESH_CONFIRMATION_LIMIT) + return { + kind: "uncertain", + reason: + "process ownership could not be revalidated: exit refresh confirmation limit exceeded", + }; + // The confirmation read is its own table. A PID classified on the previous + // table, including a readable foreign token, does not identify the process + // now using that PID. Read newcomers again, read a pre-exit PID again + // when its parent or group changed, and read a PID again when its token + // was already read even if that parent and group have returned. + current = liveRows; + inspected.clear(); + matched.length = 0; + pending = current.filter( + (process) => + tokenRead.has(process.pid) || + isExitRefreshCandidate(process, ownership, initialRelations), + ); + } + const livePids = new Set(current.map(({ pid }) => pid)); + const stillMatched = matched.filter((process) => livePids.has(process.pid)); + if (stillMatched.length === 0) return { kind: "clear", processes: current }; + const matchedPids = new Set(stillMatched.map(({ pid }) => pid)); + const groupIds = new Set(); + for (const process of stillMatched) { + const leader = current.find( + (candidate) => + candidate.pid === process.processGroupId && + candidate.processGroupId === process.processGroupId, + ); + if (leader === undefined || !matchedPids.has(leader.pid)) + return { + kind: "uncertain", + reason: + "descendant process-group leader identity could not be verified", + }; + groupIds.add(process.processGroupId); + } + return { + kind: "escaped", + processes: current, + groupIds: [...groupIds].sort((left, right) => left - right), + }; +}; + // A process-table read can race exit and retain the old state with an altered // command. Native terminal facts can settle that mismatch; exit-in-progress // must be confirmed first. Surviving members still require normal token checks. @@ -570,11 +768,11 @@ const settleLauncherExit = async ( host: ProcessOwnershipHost, exitSettlement: ProcessExitSettlementBudget, ): Promise< - | readonly ProcessTableEntry[] + | SettledProcessTable | Extract > => { const launcher = processes.find(({ pid }) => pid === ownership.leaderPid); - if (launcher === undefined) return processes; + if (launcher === undefined) return settledProcessTable(processes); let identityCheckedLauncher = launcher; if (host.processIdentities !== undefined) { const initialIdentities = await readNativeProcessIdentityMap( @@ -605,7 +803,9 @@ const settleLauncherExit = async ( reason: "owned launcher exit could not be confirmed", }; if (processIdentityHasExited(identity)) - return processes.filter(({ pid }) => pid !== launcher.pid); + return settledProcessTable( + processes.filter(({ pid }) => pid !== launcher.pid), + ); if ( identity?.state !== "readable" || identity.relation?.state !== "observed" @@ -621,7 +821,7 @@ const settleLauncherExit = async ( }; } const reason = launcherIdentityFailure(identityCheckedLauncher, ownership); - if (reason === null) return processes; + if (reason === null) return settledProcessTable(processes); // A native identity read already says that the launcher still exists and // supplies its current parent/group relation. Refreshing the process table // cannot repair a command or launch-relation mismatch for that generation. @@ -654,7 +854,36 @@ const settleLauncherExit = async ( ) ) return { cleaned: false, reason }; - return refreshed.processes; + const initialRelations = new Map( + processes.map((process) => [ + process.pid, + { + parentPid: process.parentPid, + processGroupId: process.processGroupId, + }, + ]), + ); + const unlinked = refreshed.processes.filter( + (process) => + isExitRefreshCandidate(process, ownership, initialRelations) && + !descendantPids.has(process.pid) && + !descendantGroupIds.has(process.processGroupId) && + !refreshedDescendantPids.has(process.pid), + ); + const classified = await classifyUnlinkedExitRows( + ownership, + refreshed.processes, + unlinked, + host, + initialRelations, + ); + if (classified.kind === "uncertain") + return { cleaned: false, reason: classified.reason }; + return { + processes: classified.processes, + escapedProcessGroupIds: + classified.kind === "escaped" ? classified.groupIds : [], + }; } return { cleaned: false, reason }; }; @@ -665,13 +894,15 @@ const createOwnedCleanupPlan = async ( host: ProcessOwnershipHost, exitSettlement: ProcessExitSettlementBudget, ): Promise => { - const processes = await settleLauncherExit( + const settled = await settleLauncherExit( ownership, initialProcesses, host, exitSettlement, ); - if ("cleaned" in processes) return processes; + if ("cleaned" in settled) return settled; + const processes = settled.processes; + const escapedProcessGroupIds = new Set(settled.escapedProcessGroupIds); const relationSnapshot = await readProcessRelationSnapshot( ownership, processes, @@ -714,6 +945,7 @@ const createOwnedCleanupPlan = async ( launcher === undefined && rootMembers.length === 0 && tokenOwned.owned.length === 0 && + escapedProcessGroupIds.size === 0 && !relationSnapshot.currentRelations.some(({ processGroupId }) => sampledProcessGroupIds.has(processGroupId), ) @@ -748,6 +980,8 @@ const createOwnedCleanupPlan = async ( processGroupIds.add(process.processGroupId); for (const processGroupId of sampledProcessGroupIds) processGroupIds.add(processGroupId); + for (const processGroupId of escapedProcessGroupIds) + processGroupIds.add(processGroupId); const signalableGroups = new Set(); const unresolved = [...tokenOwned.failures, ...relationSnapshot.failures]; const unverifiableSampledGroups = new Set(); @@ -757,9 +991,12 @@ const createOwnedCleanupPlan = async ( ({ pid, processGroupId: observedGroupId }) => pid === processGroupId && observedGroupId === processGroupId, ); + const provenGroup = + tokenOwnedGroupIds.has(processGroupId) || + escapedProcessGroupIds.has(processGroupId); if ( (groupLeader === undefined || !descendantPids.has(groupLeader.pid)) && - !tokenOwnedGroupIds.has(processGroupId) && + !provenGroup && !sampledProcessGroupIds.has(processGroupId) ) return { @@ -769,7 +1006,7 @@ const createOwnedCleanupPlan = async ( }; if ( (groupLeader === undefined || !descendantPids.has(groupLeader.pid)) && - !tokenOwnedGroupIds.has(processGroupId) + !provenGroup ) unverifiableSampledGroups.add(processGroupId); } @@ -864,16 +1101,25 @@ const revalidateOwnedProcessGroup = async ( host: ProcessOwnershipHost, expectedIdentities: ReadonlyMap, exitSettlement: ProcessExitSettlementBudget, -): Promise<{ readonly empty: boolean } | ProcessCleanupResult> => { +): Promise< + | { + readonly empty: boolean; + readonly escapedProcessGroupIds: readonly number[]; + } + | ProcessCleanupResult +> => { let members: readonly ProcessTableEntry[]; + let escapedProcessGroupIds: readonly number[] = []; try { - const processes = await settleLauncherExit( + const settled = await settleLauncherExit( ownership, liveProcesses(await host.listProcesses()), host, exitSettlement, ); - if ("cleaned" in processes) return processes; + if ("cleaned" in settled) return settled; + escapedProcessGroupIds = settled.escapedProcessGroupIds; + const processes = settled.processes; const relationSnapshot = await readProcessRelationSnapshot( ownership, processes, @@ -910,7 +1156,7 @@ const revalidateOwnedProcessGroup = async ( reason: "descendant process-group leader identity could not be revalidated", }; - return { empty: members.length === 0 }; + return { empty: members.length === 0, escapedProcessGroupIds }; }; const cleanupValidationFailure = ( diff --git a/src/process/ProcessOwnership.validation.test.ts b/src/process/ProcessOwnership.validation.test.ts index a2ee26b029..48b05522fe 100644 --- a/src/process/ProcessOwnership.validation.test.ts +++ b/src/process/ProcessOwnership.validation.test.ts @@ -310,6 +310,577 @@ describe("owned process-group cleanup validation: refreshed descendant lineage", ); }); +describe("identityless exit refresh signals a token-owned replacement", () => { + it("token-checks a reparented grandchild that appears only after the launcher exits", async () => { + const child = { ...launcher, pid: 101, parentPid: 100 }; + const escaped = { + ...launcher, + pid: 102, + parentPid: 1, + processGroupId: 102, + command: "reparented-grandchild", + }; + const signalGroup = vi.fn(); + const environment = vi.fn((pid: number) => + Promise.resolve({ + REA_PROCESS_RUN_ID: pid === 102 ? ownership.runId : "run-token", + }), + ); + const listProcesses = vi + .fn() + .mockResolvedValueOnce([{ ...launcher, command: "[MainThread]" }, child]) + .mockResolvedValue([escaped]); + await expect( + cleanupOwnedProcessGroup( + { ...ownership, expectedCommand: "fixture" }, + { listProcesses, environment, signalGroup }, + ), + ).resolves.toEqual({ cleaned: true, signaled: true }); + expect(environment).toHaveBeenCalledWith(102); + expect(signalGroup.mock.calls).toEqual([[102, "SIGKILL"]]); + }); + + it("does not token-read rows that were already outside the exited snapshot", async () => { + const unrelated = { + ...launcher, + pid: 50, + parentPid: 1, + processGroupId: 50, + command: "unrelated", + }; + const escaped = { + ...launcher, + pid: 102, + parentPid: 1, + processGroupId: 102, + command: "reparented-grandchild", + }; + const signalGroup = vi.fn(); + const environment = vi.fn((pid: number) => + Promise.resolve({ + REA_PROCESS_RUN_ID: pid === 102 ? ownership.runId : "other-run", + }), + ); + const listProcesses = vi + .fn() + .mockResolvedValueOnce([ + { ...launcher, command: "[MainThread]" }, + { ...launcher, pid: 101, parentPid: 100 }, + unrelated, + ]) + .mockResolvedValue([unrelated, escaped]); + await expect( + cleanupOwnedProcessGroup( + { ...ownership, expectedCommand: "fixture" }, + { listProcesses, environment, signalGroup }, + ), + ).resolves.toEqual({ cleaned: true, signaled: true }); + expect(environment.mock.calls.map(([pid]) => pid)).not.toContain(50); + expect(signalGroup.mock.calls).toEqual([[102, "SIGKILL"]]); + }); +}); + +describe("identityless exit refresh leaves unrelated replacements unresolved", () => { + it.each([ + ["foreign", "other-run"], + ["absent", undefined], + ] as const)( + "does not signal a %s replacement row after the launcher exits", + async (_label, runId) => { + const signalGroup = vi.fn(); + const environment = vi.fn(() => + Promise.resolve( + runId === undefined ? {} : { REA_PROCESS_RUN_ID: runId }, + ), + ); + const listProcesses = vi + .fn() + .mockResolvedValueOnce([ + { ...launcher, command: "[MainThread]" }, + { ...launcher, pid: 101, parentPid: 100 }, + ]) + .mockResolvedValue([ + { + ...launcher, + pid: 102, + parentPid: 1, + processGroupId: 102, + command: "foreign-replacement", + }, + ]); + await expect( + cleanupOwnedProcessGroup( + { ...ownership, expectedCommand: "fixture" }, + { listProcesses, environment, signalGroup }, + ), + ).resolves.toEqual({ cleaned: true, signaled: false }); + expect(environment).toHaveBeenCalledWith(102); + expect(signalGroup).not.toHaveBeenCalled(); + }, + ); + + it("keeps cleanup unresolved when a replacement row's token cannot be read", async () => { + const signalGroup = vi.fn(); + const listProcesses = vi + .fn() + .mockResolvedValueOnce([ + { ...launcher, command: "[MainThread]" }, + { ...launcher, pid: 101, parentPid: 100 }, + ]) + .mockResolvedValue([ + { + ...launcher, + pid: 102, + parentPid: 1, + processGroupId: 102, + command: "unreadable-replacement", + }, + ]); + await expect( + cleanupOwnedProcessGroup( + { ...ownership, expectedCommand: "fixture" }, + { + listProcesses, + environment: () => + Promise.reject(new Error("environment unavailable")), + signalGroup, + }, + ), + ).resolves.toMatchObject({ + cleaned: false, + reason: + "process ownership could not be revalidated for PID 102: environment unavailable", + }); + expect(signalGroup).not.toHaveBeenCalled(); + }); + + it("does not signal a token-owned replacement whose group leader is absent", async () => { + const signalGroup = vi.fn(); + const listProcesses = vi + .fn() + .mockResolvedValueOnce([ + { ...launcher, command: "[MainThread]" }, + { ...launcher, pid: 101, parentPid: 100 }, + ]) + .mockResolvedValue([ + { + ...launcher, + pid: 102, + parentPid: 1, + processGroupId: 999, + command: "member-without-leader", + }, + ]); + await expect( + cleanupOwnedProcessGroup( + { ...ownership, expectedCommand: "fixture" }, + { + listProcesses, + environment: () => + Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId }), + signalGroup, + }, + ), + ).resolves.toMatchObject({ + cleaned: false, + reason: "descendant process-group leader identity could not be verified", + }); + expect(signalGroup).not.toHaveBeenCalled(); + }); +}); + +describe("identityless exit refresh keeps later confirmation rows", () => { + it("token-checks a replacement born in the liveness table after an unreadable row exits", async () => { + const unreadable = { + ...launcher, + pid: 102, + parentPid: 1, + processGroupId: 102, + command: "unreadable-replacement", + }; + const escaped = { + ...launcher, + pid: 103, + parentPid: 1, + processGroupId: 103, + command: "owned-replacement", + }; + const signalGroup = vi.fn(); + const environment = vi.fn((pid: number) => + pid === 102 + ? Promise.reject(new Error("environment unavailable")) + : Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId }), + ); + const listProcesses = vi + .fn() + .mockResolvedValueOnce([ + { ...launcher, command: "[MainThread]" }, + { ...launcher, pid: 101, parentPid: 100 }, + ]) + .mockResolvedValueOnce([unreadable]) + .mockResolvedValue([escaped]); + await expect( + cleanupOwnedProcessGroup( + { ...ownership, expectedCommand: "fixture" }, + { listProcesses, environment, signalGroup }, + ), + ).resolves.toEqual({ cleaned: true, signaled: true }); + expect(environment).toHaveBeenCalledWith(103); + expect(signalGroup.mock.calls).toEqual([[103, "SIGKILL"]]); + }); + + it("signals an escaped group discovered while revalidating the planned group", async () => { + const escaped = { + ...launcher, + pid: 102, + parentPid: 1, + processGroupId: 102, + command: "reparented-grandchild", + }; + const signalGroup = vi.fn(); + const listProcesses = vi + .fn() + .mockResolvedValueOnce([launcher]) + .mockResolvedValueOnce([{ ...launcher, command: "[MainThread]" }]) + .mockResolvedValue([escaped]); + await expect( + cleanupOwnedProcessGroup( + { ...ownership, expectedCommand: "fixture" }, + { + listProcesses, + environment: () => + Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId }), + signalGroup, + }, + ), + ).resolves.toEqual({ cleaned: true, signaled: true }); + expect(signalGroup.mock.calls).toEqual([[102, "SIGKILL"]]); + }); + + it("stays unresolved when exit-refresh confirmations do not settle", async () => { + const signalGroup = vi.fn(); + let nextPid = 200; + const listProcesses = vi + .fn() + .mockResolvedValueOnce([ + { ...launcher, command: "[MainThread]" }, + { ...launcher, pid: 101, parentPid: 100 }, + ]) + .mockImplementation(() => { + const pid = nextPid; + nextPid += 1; + return Promise.resolve([ + { + ...launcher, + pid, + parentPid: 1, + processGroupId: pid, + command: "churn", + }, + ]); + }); + await expect( + cleanupOwnedProcessGroup( + { ...ownership, expectedCommand: "fixture" }, + { + listProcesses, + environment: () => + Promise.reject(new Error("environment unavailable")), + signalGroup, + }, + ), + ).resolves.toMatchObject({ + cleaned: false, + reason: + "process ownership could not be revalidated: exit refresh confirmation limit exceeded", + }); + expect(signalGroup).not.toHaveBeenCalled(); + }); +}); + +describe("identityless exit refresh follows a helper that changes groups", () => { + it("signals the group a token-owned member joins during confirmation", async () => { + const leader = { + ...launcher, + pid: 102, + parentPid: 1, + processGroupId: 102, + command: "owned-leader", + }; + const member = { + ...launcher, + pid: 103, + parentPid: 102, + processGroupId: 102, + command: "owned-member", + }; + const moved = { ...member, parentPid: 1, processGroupId: 103 }; + const unreadable = { + ...launcher, + pid: 104, + parentPid: 1, + processGroupId: 104, + command: "unreadable", + }; + const signalGroup = vi.fn(); + const listProcesses = vi + .fn() + .mockResolvedValueOnce([ + { ...launcher, command: "[MainThread]" }, + { ...launcher, pid: 101, parentPid: 100 }, + ]) + .mockResolvedValueOnce([leader, member, unreadable]) + .mockResolvedValue([leader, moved]); + await expect( + cleanupOwnedProcessGroup( + { ...ownership, expectedCommand: "fixture" }, + { + listProcesses, + environment: (pid) => + pid === 104 + ? Promise.reject(new Error("environment unavailable")) + : Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId }), + signalGroup, + }, + ), + ).resolves.toEqual({ cleaned: true, signaled: true }); + expect(signalGroup.mock.calls).toEqual([ + [102, "SIGKILL"], + [103, "SIGKILL"], + ]); + }); +}); + +describe("identityless exit refresh rechecks a prior PID whose relation changes", () => { + it("token-checks a pre-existing PID after its parent changes", async () => { + const unrelated = { + ...launcher, + pid: 50, + parentPid: 999, + processGroupId: 50, + command: "unrelated", + }; + const stable = { + ...launcher, + pid: 60, + parentPid: 1, + processGroupId: 60, + command: "stable-outsider", + }; + const signalGroup = vi.fn(); + const environment = vi.fn((pid: number) => + Promise.resolve({ + REA_PROCESS_RUN_ID: pid === 50 ? ownership.runId : "other-run", + }), + ); + const listProcesses = vi + .fn() + .mockResolvedValueOnce([ + { ...launcher, command: "[MainThread]" }, + { ...launcher, pid: 101, parentPid: 100 }, + unrelated, + stable, + ]) + .mockResolvedValue([{ ...unrelated, parentPid: 1 }, stable]); + await expect( + cleanupOwnedProcessGroup( + { ...ownership, expectedCommand: "fixture" }, + { listProcesses, environment, signalGroup }, + ), + ).resolves.toEqual({ cleaned: true, signaled: true }); + expect(environment).toHaveBeenCalledWith(50); + expect(environment.mock.calls.map(([pid]) => pid)).not.toContain(60); + expect(signalGroup.mock.calls).toEqual([[50, "SIGKILL"]]); + }); +}); + +describe("identityless exit refresh keeps a token-read helper", () => { + it("rechecks a matched helper after it returns to its original group", async () => { + const helper = { + ...launcher, + pid: 50, + parentPid: 1, + processGroupId: 50, + command: "helper", + }; + const leader = { + ...launcher, + pid: 60, + parentPid: 1, + processGroupId: 60, + command: "owned-leader", + }; + const stable = { + ...launcher, + pid: 70, + parentPid: 1, + processGroupId: 70, + command: "stable-outsider", + }; + const unreadable = { + ...launcher, + pid: 104, + parentPid: 1, + processGroupId: 104, + command: "unreadable", + }; + const signalGroup = vi.fn(); + const environment = vi.fn((pid: number) => + pid === 104 + ? Promise.reject(new Error("environment unavailable")) + : Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId }), + ); + const listProcesses = vi + .fn() + .mockResolvedValueOnce([ + { ...launcher, command: "[MainThread]" }, + { ...launcher, pid: 101, parentPid: 100 }, + helper, + stable, + ]) + .mockResolvedValueOnce([ + leader, + { ...helper, parentPid: 60, processGroupId: 60 }, + unreadable, + stable, + ]) + .mockResolvedValue([leader, helper, stable]); + await expect( + cleanupOwnedProcessGroup( + { ...ownership, expectedCommand: "fixture" }, + { listProcesses, environment, signalGroup }, + ), + ).resolves.toEqual({ cleaned: true, signaled: true }); + expect(environment.mock.calls.map(([pid]) => pid)).not.toContain(70); + expect(signalGroup.mock.calls).toEqual([ + [50, "SIGKILL"], + [60, "SIGKILL"], + ]); + }); +}); + +describe("identityless exit refresh re-reads a reused foreign PID", () => { + it("token-checks a PID again after a foreign row exits into an owned replacement", async () => { + const reused = { + ...launcher, + pid: 102, + parentPid: 1, + processGroupId: 102, + command: "reused-pid", + }; + const unreadable = { + ...launcher, + pid: 104, + parentPid: 1, + processGroupId: 104, + command: "unreadable", + }; + let reads = 0; + const signalGroup = vi.fn(); + const environment = vi.fn((pid: number) => { + if (pid === 104) + return Promise.reject(new Error("environment unavailable")); + if (pid !== 102) + return Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId }); + reads += 1; + return Promise.resolve({ + REA_PROCESS_RUN_ID: reads === 1 ? "other-run" : ownership.runId, + }); + }); + const listProcesses = vi + .fn() + .mockResolvedValueOnce([ + { ...launcher, command: "[MainThread]" }, + { ...launcher, pid: 101, parentPid: 100 }, + ]) + .mockResolvedValueOnce([reused, unreadable]) + .mockResolvedValue([reused]); + await expect( + cleanupOwnedProcessGroup( + { ...ownership, expectedCommand: "fixture" }, + { listProcesses, environment, signalGroup }, + ), + ).resolves.toEqual({ cleaned: true, signaled: true }); + expect(reads).toBeGreaterThan(1); + expect(signalGroup.mock.calls).toEqual([[102, "SIGKILL"]]); + }); +}); + +describe("identityless cleanup revalidates a group that reappears", () => { + const leader = (pid: number, command = "fixture"): ProcessTableEntry => ({ + ...launcher, + pid, + parentPid: pid === 100 ? 1 : 100, + processGroupId: pid, + command, + }); + + it("signals a token-owned group that reappears after it was already processed", async () => { + const signalGroup = vi.fn(); + const listProcesses = vi + .fn() + .mockResolvedValueOnce([leader(100), leader(101)]) + .mockResolvedValueOnce([leader(100)]) + .mockResolvedValueOnce([leader(100, "[MainThread]")]) + .mockResolvedValueOnce([leader(102)]) + .mockResolvedValueOnce([leader(100, "[MainThread]")]) + .mockResolvedValueOnce([leader(101)]) + .mockResolvedValue([leader(101)]); + await expect( + cleanupOwnedProcessGroup( + { ...ownership, expectedCommand: "fixture" }, + { + listProcesses, + environment: () => + Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId }), + signalGroup, + }, + ), + ).resolves.toEqual({ cleaned: true, signaled: true }); + expect(signalGroup.mock.calls).toEqual([ + [100, "SIGKILL"], + [101, "SIGKILL"], + ]); + }); + + it("stays unresolved when a processed group keeps reappearing", async () => { + const signalGroup = vi.fn(); + const tables = [ + [leader(100), leader(101)], + [leader(100)], + [leader(100, "[MainThread]")], + [leader(102)], + [leader(100, "[MainThread]")], + [leader(101)], + [leader(100, "[MainThread]")], + [leader(102)], + [leader(100, "[MainThread]")], + [leader(101)], + ]; + let reads = 0; + const listProcesses = vi.fn(() => { + const table = tables[reads] ?? [leader(101)]; + reads += 1; + return Promise.resolve(table); + }); + await expect( + cleanupOwnedProcessGroup( + { ...ownership, expectedCommand: "fixture" }, + { + listProcesses, + environment: () => + Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId }), + signalGroup, + }, + ), + ).resolves.toMatchObject({ + cleaned: false, + reason: "owned process group 101 reappeared after revalidation", + }); + expect(signalGroup.mock.calls).toEqual([[100, "SIGKILL"]]); + }); +}); + describe("owned process-group cleanup validation: launcher exit races", () => { it.each([101, 999])( "retains cleanup uncertainty for a detached descendant in group %s after launcher exit", diff --git a/src/process/ProcessOwnershipObservation.ts b/src/process/ProcessOwnershipObservation.ts index fd287a031f..24608c2e3f 100644 --- a/src/process/ProcessOwnershipObservation.ts +++ b/src/process/ProcessOwnershipObservation.ts @@ -239,6 +239,27 @@ export const observeOwnedProcessLineage = async ( return unavailableLineage(ownership, identityFailure); const descendants = descendantsOf([launcher.pid], processes); const verifiedDescendants: ProcessTableEntry[] = []; + // Seed every initial row. A later refresh reads a row again when its parent, + // group, or launcher-tree membership changes. A row whose token was read is + // read again on every later refresh: parent and group are not a generation. + const initialTree = new Set(descendants.map(({ pid }) => pid)); + const initialRelations = new Map(); + const examinedRefresh = new Map(); + for (const process of processes) { + if (process.pid === launcher.pid) continue; + const relation: ExaminedLineageRefresh = { + parentPid: process.parentPid, + processGroupId: process.processGroupId, + inTree: initialTree.has(process.pid), + tokenChecked: false, + descendant: undefined, + }; + initialRelations.set(process.pid, relation); + examinedRefresh.set(process.pid, relation); + } + let refreshDescendants: readonly ProcessTableEntry[] = []; + let suppressedRefreshPids = new Set(); + let latestRefreshPids: ReadonlySet | undefined; for await (const { process: member, observation } of readProcessRunTokens( host, [launcher, ...descendants], @@ -250,19 +271,43 @@ export const observeOwnedProcessLineage = async ( "process lineage contains an unowned or PID-reused process", ); if (member.pid !== launcher.pid) verifiedDescendants.push(member); + const seeded = examinedRefresh.get(member.pid); + if (seeded !== undefined) + examinedRefresh.set(member.pid, { ...seeded, tokenChecked: true }); } else { - if (await processIsGone(host, member.pid)) { - if (member.pid === launcher.pid) - return unavailableLineage( - ownership, - "owned launcher exited during lineage validation", - ); - continue; + let refreshed: readonly ProcessTableEntry[]; + try { + refreshed = liveProcesses(await host.listProcesses()); + } catch (cause: unknown) { + void cause; + return unavailableLineage( + ownership, + `process ownership could not be revalidated for PID ${member.pid}: ${observation.reason}`, + ); } - return unavailableLineage( + if (refreshed.some((process) => process.pid === member.pid)) + return unavailableLineage( + ownership, + `process ownership could not be revalidated for PID ${member.pid}: ${observation.reason}`, + ); + if (member.pid === launcher.pid) + return unavailableLineage( + ownership, + "owned launcher exited during lineage validation", + ); + const classified = await classifyIdentitylessLineageRefresh( ownership, - `process ownership could not be revalidated for PID ${member.pid}: ${observation.reason}`, + launcher.pid, + refreshed, + host, + examinedRefresh, + initialRelations, ); + if (classified.status === "unavailable") + return unavailableLineage(ownership, classified.reason); + refreshDescendants = classified.descendants; + suppressedRefreshPids = new Set(classified.suppressedPids); + latestRefreshPids = new Set(refreshed.map(({ pid }) => pid)); } } return { @@ -273,7 +318,15 @@ export const observeOwnedProcessLineage = async ( launcherPid: launcher.pid, launcherParentPid: launcher.parentPid, processGroupId: launcher.processGroupId, - descendants: verifiedDescendants + descendants: [ + ...verifiedDescendants.filter( + ({ pid }) => + (latestRefreshPids === undefined || latestRefreshPids.has(pid)) && + !suppressedRefreshPids.has(pid) && + !refreshDescendants.some((descendant) => descendant.pid === pid), + ), + ...refreshDescendants, + ] .sort((left, right) => left.pid - right.pid) .map(({ pid, parentPid, processGroupId }) => ({ pid, @@ -431,6 +484,145 @@ const observeNativeOwnedProcessLineage = async ( }; }; +/** Relation already classified for one PID seen by an identityless refresh. */ +interface ExaminedLineageRefresh { + readonly parentPid: number; + readonly processGroupId: number; + /** True when this row was a launcher descendant at this classification. */ + readonly inTree: boolean; + /** True after this refresh has read the row's run token. */ + readonly tokenChecked: boolean; + readonly descendant: ProcessTableEntry | undefined; +} + +/** + * A liveness refresh is a process table, not a boolean. New rows still under + * the launcher join the published lineage after a matching token. A matching + * token outside that tree, or an unreadable live row, blocks a verified result. + * A readable foreign token outside the tree is not part of this run. A row is + * read again when its parent, process group, or launcher-tree membership + * changes. A row whose token was already read is read again on every later + * refresh, because those fields do not identify a process generation. + * Unchanged rows from the initial snapshot are not read again. The launcher + * is read on every refresh; a missing, unowned, or relation-mismatched + * launcher PID is unavailable. + */ +const classifyIdentitylessLineageRefresh = async ( + ownership: OwnedProcessGroup, + launcherPid: number, + refreshed: readonly ProcessTableEntry[], + host: ProcessOwnershipHost, + examined: Map, + initialRelations: ReadonlyMap, +): Promise< + | { + readonly status: "classified"; + readonly descendants: readonly ProcessTableEntry[]; + readonly suppressedPids: readonly number[]; + } + | { readonly status: "unavailable"; readonly reason: string } +> => { + const refreshedLauncher = refreshed.find( + (process) => process.pid === launcherPid, + ); + if (refreshedLauncher === undefined) + return { + status: "unavailable", + reason: "owned launcher exited during lineage validation", + }; + for await (const { observation } of readProcessRunTokens(host, [ + refreshedLauncher, + ])) { + if (observation.state !== "readable") + return { + status: "unavailable", + reason: `process ownership could not be revalidated for PID ${String(launcherPid)}: ${observation.reason}`, + }; + if (observation.runId !== ownership.runId) + return { + status: "unavailable", + reason: "process lineage contains an unowned or PID-reused process", + }; + } + const identityFailure = launcherIdentityFailure(refreshedLauncher, ownership); + if (identityFailure !== null) + return { status: "unavailable", reason: identityFailure }; + const inTree = new Set( + descendantsOf([launcherPid], refreshed).map(({ pid }) => pid), + ); + const due = refreshed.filter((process) => { + if (process.pid === launcherPid) return false; + const previous = examined.get(process.pid); + return ( + previous === undefined || + previous.tokenChecked || + previous.parentPid !== process.parentPid || + previous.processGroupId !== process.processGroupId || + previous.inTree !== inTree.has(process.pid) + ); + }); + if (due.length > 0) { + const included = new Map(); + const ownedOutside: number[] = []; + for await (const { process, observation } of readProcessRunTokens( + host, + due, + )) { + if (observation.state !== "readable") + return { + status: "unavailable", + reason: `process ownership could not be revalidated for PID ${String(process.pid)}: ${observation.reason}`, + }; + if (observation.runId !== ownership.runId) { + if (inTree.has(process.pid)) + return { + status: "unavailable", + reason: "process lineage contains an unowned or PID-reused process", + }; + continue; + } + if (inTree.has(process.pid)) included.set(process.pid, process); + else ownedOutside.push(process.pid); + } + if (ownedOutside.length > 0) + return { + status: "unavailable", + reason: `process lineage refresh contained owned PID(s) outside the observed descendants: ${ownedOutside + .sort((left, right) => left - right) + .join(", ")}`, + }; + for (const process of due) + examined.set(process.pid, { + parentPid: process.parentPid, + processGroupId: process.processGroupId, + inTree: inTree.has(process.pid), + tokenChecked: true, + descendant: included.get(process.pid), + }); + } + const livePids = new Set(refreshed.map(({ pid }) => pid)); + for (const pid of examined.keys()) + if (!livePids.has(pid)) examined.delete(pid); + const suppressedPids = [...examined.keys()].filter((pid) => { + const seed = initialRelations.get(pid); + const entry = examined.get(pid); + return ( + seed !== undefined && + entry !== undefined && + (seed.parentPid !== entry.parentPid || + seed.processGroupId !== entry.processGroupId || + seed.inTree !== entry.inTree) + ); + }); + return { + status: "classified", + descendants: [...examined.values()].flatMap((entry) => + entry.descendant === undefined ? [] : [entry.descendant], + ), + suppressedPids, + }; +}; + const processWithNativeRelation = ( process: ProcessTableEntry, identity: ProcessIdentityObservation,