From b2048d46d1261b7c4b036614d16e9103066a56ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B3=BD=ED=98=84?= <73919235+kwakhyun@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:25:04 +0900 Subject: [PATCH] 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 --- src/process/ProcessOwnership.identity.test.ts | 92 ++ src/process/ProcessOwnership.lineage.test.ts | 1073 +++++++++++++++++ src/process/ProcessOwnershipEvidence.ts | 107 +- src/process/ProcessOwnershipFacts.test.ts | 174 ++- src/process/ProcessOwnershipObservation.ts | 91 +- tests/acceptance/webModuleCli.test.ts | 6 +- .../process/processLineageRetention.test.ts | 160 +++ 7 files changed, 1645 insertions(+), 58 deletions(-) create mode 100644 src/process/ProcessOwnership.lineage.test.ts create mode 100644 tests/boundary/process/processLineageRetention.test.ts diff --git a/src/process/ProcessOwnership.identity.test.ts b/src/process/ProcessOwnership.identity.test.ts index 8076515f16..9cce96f93d 100644 --- a/src/process/ProcessOwnership.identity.test.ts +++ b/src/process/ProcessOwnership.identity.test.ts @@ -342,6 +342,98 @@ it.each(["omitted", "throws"] as const)( }, ); +it("keeps a matching-token replacement through omission and retries cleanup only with fresh identity proof", async () => { + const row: ProcessTableEntry = { + pid: 913, + parentPid: 1, + processGroupId: 913, + state: "S", + command: "replacement-owner", + }; + let listed = true; + let replaced = false; + let identityUnavailable = false; + let live = true; + const evidenceOwner = new ProcessOwnershipEvidenceOwner(); + const signalGroup = vi.fn(() => { + live = false; + }); + const host: ProcessOwnershipHost = { + platform: "linux", + listProcesses: async () => (listed && live ? [row] : []), + environment: async () => ({}), + processIdentities: async (entries) => + new Map( + entries.map((entry) => [ + entry.pid, + !live + ? { state: "absent" as const, reason: "not-in-snapshot" as const } + : identityUnavailable + ? { state: "unavailable" as const, reason: "native read denied" } + : observedIdentity(entry, replaced ? "new-owner" : "old-owner"), + ]), + ), + runTokens: async (entries) => { + // The token belongs to the replacement, but the surrounding identity + // reads straddle reuse and therefore cannot yet grant signal authority. + replaced = true; + return new Map( + entries.map(({ pid }) => [ + pid, + { state: "readable" as const, runId: "run-token" }, + ]), + ); + }, + signalGroup, + }; + const ownership = { + runId: "run-token", + leaderPid: 100, + processGroupId: 100, + sweepTokenOwnedProcesses: true, + evidenceOwner, + }; + + await expect( + verifyNoTokenOwnedProcesses("run-token", host, undefined, ownership), + ).resolves.toMatchObject({ cleaned: false }); + listed = false; + identityUnavailable = true; + await expect( + cleanupOwnedProcessGroup(ownership, host), + ).resolves.toMatchObject({ + cleaned: false, + failures: [{ pid: row.pid, reason: "process-identity-unavailable" }], + }); + expect(signalGroup).not.toHaveBeenCalled(); + expect(live).toBe(true); + expect(evidenceOwner.candidates).toMatchObject([ + { pid: row.pid, identity: undefined, applicability: "identity-unresolved" }, + ]); + + identityUnavailable = false; + await expect( + verifyNoTokenOwnedProcesses("run-token", host, undefined, ownership), + ).resolves.toMatchObject({ + cleaned: false, + reason: "token-owned process remained after cleanup", + }); + expect(signalGroup).not.toHaveBeenCalled(); + listed = true; + await expect( + cleanupOwnedProcessGroup(ownership, host), + ).resolves.toMatchObject({ + cleaned: true, + signaled: true, + }); + expect(signalGroup).toHaveBeenCalledExactlyOnceWith( + row.processGroupId, + "SIGKILL", + ); + expect(live).toBe(false); + expect(evidenceOwner.candidates).toEqual([]); +}); + it("retains a scoped unknown generation when a later table omits its PID", async () => { const row: ProcessTableEntry = { pid: 101, diff --git a/src/process/ProcessOwnership.lineage.test.ts b/src/process/ProcessOwnership.lineage.test.ts new file mode 100644 index 0000000000..ba13724062 --- /dev/null +++ b/src/process/ProcessOwnership.lineage.test.ts @@ -0,0 +1,1073 @@ +import { expect, it, vi } from "vitest"; + +import { + cleanupOwnedProcessGroup, + verifyNoTokenOwnedProcesses, + type ProcessIdentityObservation, + type ProcessOwnershipHost, + type ProcessTableEntry, +} from "./ProcessOwnership.js"; +import { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js"; +import { observeOwnedProcessLineage } from "./ProcessOwnershipObservation.js"; + +const launcher: ProcessTableEntry = { + pid: 100, + parentPid: 1, + processGroupId: 100, + state: "S", + command: "launcher", +}; +const child: ProcessTableEntry = { + ...launcher, + pid: 101, + parentPid: 100, + command: "child", +}; +const grandchild: ProcessTableEntry = { + ...child, + pid: 102, + parentPid: 101, + command: "grandchild", +}; + +const nativeIdentity = ( + row: ProcessTableEntry, +): Extract => ({ + state: "readable", + identity: `start-${String(row.pid)}`, + lifecycle: { state: "observed", lifecycle: "non-zombie" }, + relation: { + state: "observed", + parentPid: row.parentPid, + processGroupId: row.processGroupId, + }, +}); + +it.each( + (["parent", "group", "command"] as const).flatMap((mismatch) => + ( + ["foreign", "missing", "unavailable", "identity-unavailable"] as const + ).map((token) => ({ mismatch, token })), + ), +)( + "does not enroll a $token descendant through a launcher with mismatching $mismatch identity", + async ({ mismatch, token }) => { + const foreign = { ...child, processGroupId: child.pid }; + const independentlyOwned = { + ...grandchild, + parentPid: launcher.pid, + processGroupId: grandchild.pid, + }; + const listedLauncher = { + ...launcher, + command: mismatch === "command" ? "foreign-launcher" : launcher.command, + }; + const nativeLauncher = { + ...listedLauncher, + parentPid: mismatch === "parent" ? 999 : launcher.parentPid, + processGroupId: mismatch === "group" ? 999 : launcher.processGroupId, + }; + const evidenceOwner = new ProcessOwnershipEvidenceOwner(); + const ownership = { + runId: "run", + leaderPid: launcher.pid, + processGroupId: launcher.processGroupId, + expectedParentPid: launcher.parentPid, + expectedCommand: launcher.command, + sweepTokenOwnedProcesses: true as const, + evidenceOwner, + }; + let observing = true; + const signalGroup = vi.fn(); + const host: ProcessOwnershipHost = { + listProcesses: async () => + observing + ? [listedLauncher, foreign, independentlyOwned] + : [{ ...foreign, parentPid: 1 }], + environment: async () => ({}), + runTokens: async (rows) => + new Map( + rows.map(({ pid }) => [ + pid, + observing && + pid === foreign.pid && + (token === "unavailable" || token === "identity-unavailable") + ? { state: "unavailable" as const, reason: "token denied" } + : { + state: "readable" as const, + runId: + pid === independentlyOwned.pid + ? ownership.runId + : observing && pid === foreign.pid && token === "missing" + ? undefined + : "foreign", + }, + ]), + ), + processIdentities: async (rows) => + new Map( + rows.map((row): [number, ProcessIdentityObservation] => { + if (!observing && row.pid !== foreign.pid) + return [row.pid, { state: "absent", reason: "not-in-snapshot" }]; + if ( + observing && + row.pid === foreign.pid && + token === "identity-unavailable" + ) + return [ + row.pid, + { state: "unavailable", reason: "identity denied" }, + ]; + return [ + row.pid, + nativeIdentity(row.pid === launcher.pid ? nativeLauncher : row), + ]; + }), + ), + signalGroup, + }; + + await expect( + observeOwnedProcessLineage(ownership, host), + ).resolves.toMatchObject({ status: "unavailable" }); + const retained = evidenceOwner.candidates; + observing = false; + + // The child remains alive with readable foreign identity/token evidence + // after the reused launcher exits. Its stale relation must not block cleanup. + await expect(cleanupOwnedProcessGroup(ownership, host)).resolves.toEqual({ + cleaned: true, + signaled: false, + }); + expect(retained).toEqual([ + expect.objectContaining({ + pid: independentlyOwned.pid, + applicability: "token-verified", + }), + ]); + expect(evidenceOwner.candidates).toEqual([]); + expect(signalGroup).not.toHaveBeenCalled(); + }, +); + +it("preserves independently token-owned ancestry when the launcher identity mismatches", async () => { + const ownedParent = { ...child, processGroupId: child.pid }; + const unreadableChild = { ...grandchild, processGroupId: grandchild.pid }; + const foreignLauncher = { ...launcher, command: "foreign-launcher" }; + const evidenceOwner = new ProcessOwnershipEvidenceOwner(); + const host: ProcessOwnershipHost = { + listProcesses: async () => [foreignLauncher, ownedParent, unreadableChild], + environment: async () => ({}), + runTokens: async (rows) => + new Map( + rows.map(({ pid }) => [ + pid, + pid === unreadableChild.pid + ? { state: "unavailable" as const, reason: "token denied" } + : { + state: "readable" as const, + runId: pid === ownedParent.pid ? "run" : "foreign", + }, + ]), + ), + processIdentities: async (rows) => + new Map(rows.map((row) => [row.pid, nativeIdentity(row)])), + signalGroup: vi.fn(), + }; + + await expect( + observeOwnedProcessLineage( + { + runId: "run", + leaderPid: launcher.pid, + processGroupId: launcher.processGroupId, + expectedCommand: launcher.command, + evidenceOwner, + }, + host, + ), + ).resolves.toMatchObject({ status: "unavailable" }); + expect(evidenceOwner.candidates).toEqual([ + expect.objectContaining({ + pid: ownedParent.pid, + applicability: "token-verified", + }), + expect.objectContaining({ + pid: unreadableChild.pid, + applicability: "related-ownership-unresolved", + scopeEvidence: "current-token-owned-parent", + }), + ]); +}); + +it.each(["parent", "group"] as const)( + "preserves observed scope when the same launcher changes its %s during validation", + async (mismatch) => { + const detached = { ...child, processGroupId: child.pid }; + const evidenceOwner = new ProcessOwnershipEvidenceOwner(); + let identityReads = 0; + const host: ProcessOwnershipHost = { + listProcesses: async () => [launcher, detached], + environment: async () => ({}), + runTokens: async (rows) => + new Map( + rows.map(({ pid }) => [ + pid, + pid === detached.pid + ? { state: "unavailable" as const, reason: "token denied" } + : { state: "readable" as const, runId: "foreign" }, + ]), + ), + processIdentities: async (rows) => { + identityReads += 1; + return new Map( + rows.map((row) => [ + row.pid, + nativeIdentity( + row.pid === launcher.pid && identityReads > 1 + ? { + ...row, + parentPid: mismatch === "parent" ? 999 : row.parentPid, + processGroupId: + mismatch === "group" ? 999 : row.processGroupId, + } + : row, + ), + ]), + ); + }, + signalGroup: vi.fn(), + }; + + await expect( + observeOwnedProcessLineage( + { + runId: "run", + leaderPid: launcher.pid, + processGroupId: launcher.processGroupId, + expectedParentPid: launcher.parentPid, + evidenceOwner, + }, + host, + ), + ).resolves.toMatchObject({ status: "unavailable" }); + expect(evidenceOwner.candidates).toContainEqual( + expect.objectContaining({ + pid: detached.pid, + applicability: "related-ownership-unresolved", + scopeEvidence: "capture-leader-pid", + }), + ); + }, +); + +it.each([ + { missing: "identity", token: "matching" }, + { missing: "relation", token: "matching" }, + { missing: "relation", token: "unavailable" }, + { missing: "relation", token: "foreign" }, +] as const)( + "retains descendants with an unreadable native $missing and $token token through table omission", + async ({ missing, token }) => { + const evidenceOwner = new ProcessOwnershipEvidenceOwner(); + const ownership = { + runId: "run", + leaderPid: launcher.pid, + processGroupId: launcher.processGroupId, + sweepTokenOwnedProcesses: true as const, + evidenceOwner, + }; + let phase: "observing" | "omitted" | "exited" = "observing"; + const identityReads: number[][] = []; + const signalGroup = vi.fn(); + const host: ProcessOwnershipHost = { + listProcesses: async () => + phase === "observing" ? [launcher, child, grandchild] : [], + environment: async () => ({ REA_PROCESS_RUN_ID: ownership.runId }), + runTokens: async (rows) => + new Map( + rows.map(({ pid }) => [ + pid, + pid === child.pid && token === "unavailable" + ? { state: "unavailable" as const, reason: "token denied" } + : { + state: "readable" as const, + runId: + pid === child.pid && token === "foreign" + ? "foreign" + : ownership.runId, + }, + ]), + ), + processIdentities: async (rows) => { + identityReads.push(rows.map(({ pid }) => pid)); + return new Map( + rows.map((row): [number, ProcessIdentityObservation] => { + if ( + phase === "exited" || + (phase === "omitted" && row.pid !== child.pid) + ) + return [row.pid, { state: "absent", reason: "not-in-snapshot" }]; + if (row.pid !== child.pid) return [row.pid, nativeIdentity(row)]; + if (missing === "identity") + return [ + row.pid, + { state: "unavailable", reason: "native identity denied" }, + ]; + return [ + row.pid, + { + ...nativeIdentity(row), + relation: { state: "unavailable", reason: "relation denied" }, + }, + ]; + }), + ); + }, + signalGroup, + }; + + await expect( + observeOwnedProcessLineage(ownership, host), + ).resolves.toMatchObject({ + status: "unavailable", + }); + expect(evidenceOwner.candidates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ pid: child.pid }), + expect.objectContaining({ + pid: grandchild.pid, + applicability: "token-verified", + }), + ]), + ); + if (token !== "matching") + expect(evidenceOwner.candidates).toContainEqual( + expect.objectContaining({ + pid: child.pid, + identity: "start-101", + applicability: "related-ownership-unresolved", + scopeEvidence: "unverified-table-relation", + }), + ); + + phase = "omitted"; + identityReads.length = 0; + await expect( + cleanupOwnedProcessGroup(ownership, host), + ).resolves.toMatchObject({ + cleaned: false, + failures: [ + expect.objectContaining({ + pid: child.pid, + reason: + token === "unavailable" + ? "environment-unreadable" + : token === "foreign" + ? "run-token-mismatch" + : "process-identity-unavailable", + }), + ], + }); + expect(identityReads.flat()).toContain(child.pid); + expect(evidenceOwner.candidates).toHaveLength(1); + + phase = "exited"; + await expect(cleanupOwnedProcessGroup(ownership, host)).resolves.toEqual({ + cleaned: true, + signaled: false, + }); + expect(evidenceOwner.candidates).toEqual([]); + expect(signalGroup).not.toHaveBeenCalled(); + }, +); + +it("retains a live native descendant behind an exited table ancestor", async () => { + const evidenceOwner = new ProcessOwnershipEvidenceOwner(); + const tokenReads: number[] = []; + const host: ProcessOwnershipHost = { + listProcesses: async () => [launcher, child, grandchild], + environment: async (pid) => { + tokenReads.push(pid); + return { REA_PROCESS_RUN_ID: "run" }; + }, + processIdentities: async (rows) => + new Map( + rows.map((row): [number, ProcessIdentityObservation] => [ + row.pid, + row.pid === child.pid + ? { state: "absent", reason: "not-in-snapshot" } + : nativeIdentity(row), + ]), + ), + signalGroup: vi.fn(), + }; + + await expect( + observeOwnedProcessLineage( + { runId: "run", leaderPid: 100, processGroupId: 100, evidenceOwner }, + host, + ), + ).resolves.toMatchObject({ status: "unavailable" }); + expect(tokenReads).toContain(grandchild.pid); + expect(evidenceOwner.candidates).toContainEqual( + expect.objectContaining({ + pid: grandchild.pid, + applicability: "token-verified", + }), + ); + expect(evidenceOwner.candidates.map(({ pid }) => pid)).not.toContain( + child.pid, + ); +}); + +it.each(["unavailable-relation", "absent"] as const)( + "retains detached unreadable descendants through an ancestor with %s native facts", + async (ancestor) => { + const evidenceOwner = new ProcessOwnershipEvidenceOwner(); + const ownership = { + runId: "run", + leaderPid: 100, + processGroupId: 100, + evidenceOwner, + }; + const detached = { ...grandchild, processGroupId: grandchild.pid }; + let omitted = false; + const host: ProcessOwnershipHost = { + listProcesses: async () => (omitted ? [] : [launcher, child, detached]), + environment: async () => ({}), + runTokens: async (rows) => + new Map( + rows.map(({ pid }) => [ + pid, + pid === launcher.pid + ? { state: "readable" as const, runId: "run" } + : { state: "unavailable" as const, reason: "token denied" }, + ]), + ), + processIdentities: async (rows) => + new Map( + rows.map((row): [number, ProcessIdentityObservation] => [ + row.pid, + (omitted && row.pid === launcher.pid) || + (ancestor === "absent" && row.pid === child.pid) + ? { state: "absent", reason: "not-in-snapshot" } + : row.pid === child.pid + ? { + ...nativeIdentity(row), + relation: { + state: "unavailable", + reason: "relation denied", + }, + } + : nativeIdentity(row), + ]), + ), + signalGroup: vi.fn(), + }; + + await expect( + observeOwnedProcessLineage(ownership, host), + ).resolves.toMatchObject({ status: "unavailable" }); + expect(evidenceOwner.candidates).toContainEqual( + expect.objectContaining({ + pid: grandchild.pid, + identity: "start-102", + applicability: "related-ownership-unresolved", + scopeEvidence: "unverified-table-relation", + }), + ); + + omitted = true; + await expect( + verifyNoTokenOwnedProcesses("run", host, [], ownership), + ).resolves.toMatchObject({ + cleaned: false, + failures: expect.arrayContaining([ + expect.objectContaining({ + pid: grandchild.pid, + reason: "environment-unreadable", + }), + ]), + }); + expect(host.signalGroup).not.toHaveBeenCalled(); + }, +); + +it("retains a verified child before rejecting an unreadable launcher identity", async () => { + const evidenceOwner = new ProcessOwnershipEvidenceOwner(); + const host: ProcessOwnershipHost = { + listProcesses: async () => [launcher, child], + environment: async () => ({ REA_PROCESS_RUN_ID: "run" }), + processIdentities: async (rows) => + new Map( + rows.map((row): [number, ProcessIdentityObservation] => [ + row.pid, + row.pid === launcher.pid + ? { state: "unavailable", reason: "native identity denied" } + : nativeIdentity(row), + ]), + ), + signalGroup: vi.fn(), + }; + + await expect( + observeOwnedProcessLineage( + { runId: "run", leaderPid: 100, processGroupId: 100, evidenceOwner }, + host, + ), + ).resolves.toMatchObject({ status: "unavailable" }); + expect(evidenceOwner.candidates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + pid: launcher.pid, + applicability: "identity-unresolved", + }), + expect.objectContaining({ + pid: child.pid, + applicability: "token-verified", + }), + ]), + ); +}); + +it("uses a readable native relation to exclude a stale table descendant", async () => { + const evidenceOwner = new ProcessOwnershipEvidenceOwner(); + const tokenReads: number[] = []; + const host: ProcessOwnershipHost = { + listProcesses: async () => [launcher, child], + environment: async (pid) => { + tokenReads.push(pid); + return { REA_PROCESS_RUN_ID: pid === launcher.pid ? "run" : "foreign" }; + }, + processIdentities: async (rows) => + new Map( + rows.map((row) => [ + row.pid, + nativeIdentity( + row.pid === child.pid + ? { ...row, parentPid: 1, processGroupId: 200 } + : row, + ), + ]), + ), + signalGroup: vi.fn(), + }; + + await expect( + observeOwnedProcessLineage( + { runId: "run", leaderPid: 100, processGroupId: 100, evidenceOwner }, + host, + ), + ).resolves.toMatchObject({ + status: "verified", + lineage: { descendants: [] }, + }); + expect(tokenReads).toEqual([launcher.pid]); + expect(evidenceOwner.candidates.map(({ pid }) => pid)).toEqual([ + launcher.pid, + ]); +}); + +it("does not retain table scope after a native relation resolves as unrelated", async () => { + const evidenceOwner = new ProcessOwnershipEvidenceOwner(); + let identityReads = 0; + const host: ProcessOwnershipHost = { + listProcesses: async () => [launcher, child], + environment: async (pid) => ({ + REA_PROCESS_RUN_ID: pid === launcher.pid ? "run" : "foreign", + }), + processIdentities: async (rows) => { + identityReads += 1; + return new Map( + rows.map((row): [number, ProcessIdentityObservation] => [ + row.pid, + row.pid !== child.pid + ? nativeIdentity(row) + : identityReads === 1 + ? { + ...nativeIdentity(row), + relation: { state: "unavailable", reason: "relation denied" }, + } + : nativeIdentity({ ...row, parentPid: 1, processGroupId: 200 }), + ]), + ); + }, + signalGroup: vi.fn(), + }; + + await expect( + observeOwnedProcessLineage( + { runId: "run", leaderPid: 100, processGroupId: 100, evidenceOwner }, + host, + ), + ).resolves.toMatchObject({ status: "unavailable" }); + expect(evidenceOwner.candidates.map(({ pid }) => pid)).toEqual([ + launcher.pid, + ]); +}); + +it.each(["none", "unresolved", "known"] as const)( + "retires a relation-unreadable descendant replaced during observation (prior identity: %s)", + async (previous) => { + const replacement = { ...child, parentPid: 1, processGroupId: 200 }; + const evidenceOwner = new ProcessOwnershipEvidenceOwner(); + const ownership = { + runId: "run", + leaderPid: launcher.pid, + processGroupId: launcher.processGroupId, + sweepTokenOwnedProcesses: true as const, + evidenceOwner, + }; + let phase: "retaining" | "replacing" | "cleanup" = + previous === "none" ? "replacing" : "retaining"; + let identityReads = 0; + const signalGroup = vi.fn(); + const host: ProcessOwnershipHost = { + listProcesses: async () => + phase === "cleanup" ? [replacement] : [launcher, child], + environment: async (pid) => ({ + REA_PROCESS_RUN_ID: pid === launcher.pid ? "run" : "foreign", + }), + processIdentities: async (rows) => { + identityReads += 1; + return new Map( + rows.map((row): [number, ProcessIdentityObservation] => { + if (row.pid === launcher.pid) + return [ + row.pid, + phase === "cleanup" + ? { state: "absent", reason: "not-in-snapshot" } + : nativeIdentity(row), + ]; + if (phase === "retaining" && previous === "unresolved") + return [ + row.pid, + { state: "unavailable", reason: "identity denied" }, + ]; + if ( + phase === "retaining" || + (phase === "replacing" && identityReads === 1) + ) + return [ + row.pid, + { + ...nativeIdentity(row), + relation: { state: "unavailable", reason: "relation denied" }, + }, + ]; + return [ + row.pid, + { ...nativeIdentity(replacement), identity: "replacement-101" }, + ]; + }), + ); + }, + signalGroup, + }; + + if (previous !== "none") { + await observeOwnedProcessLineage(ownership, host); + expect(evidenceOwner.candidates).toContainEqual( + expect.objectContaining({ + pid: child.pid, + identity: previous === "known" ? "start-101" : undefined, + }), + ); + phase = "replacing"; + identityReads = 0; + } + const lineage = await observeOwnedProcessLineage(ownership, host); + const retained = evidenceOwner.candidates; + phase = "cleanup"; + + // The unrelated replacement stays alive and listed. Its foreign token + // must not keep the exited generation's cleanup pending or authorize a signal. + await expect(cleanupOwnedProcessGroup(ownership, host)).resolves.toEqual({ + cleaned: true, + signaled: false, + }); + expect(lineage.status).toBe("unavailable"); + expect(retained.map(({ pid }) => pid)).not.toContain(child.pid); + expect(evidenceOwner.candidates).toEqual([]); + expect(signalGroup).not.toHaveBeenCalled(); + }, +); + +it.each(["replaced", "relation-unavailable-replaced", "unavailable"] as const)( + "retains an omitted descendant when its ancestor's generation is %s", + async (ancestorGeneration) => { + const evidenceOwner = new ProcessOwnershipEvidenceOwner(); + const ownership = { + runId: "run", + leaderPid: launcher.pid, + processGroupId: launcher.processGroupId, + sweepTokenOwnedProcesses: true as const, + evidenceOwner, + }; + const detached = { ...grandchild, processGroupId: grandchild.pid }; + let phase: "observing" | "omitted" | "exited" = "observing"; + let identityReads = 0; + const signalGroup = vi.fn(); + const host: ProcessOwnershipHost = { + listProcesses: async () => + phase === "observing" ? [launcher, child, detached] : [], + environment: async () => ({}), + runTokens: async (rows) => + new Map( + rows.map(({ pid }) => [ + pid, + pid === detached.pid + ? { state: "unavailable" as const, reason: "token denied" } + : { + state: "readable" as const, + runId: pid === launcher.pid ? "run" : "foreign", + }, + ]), + ), + processIdentities: async (rows) => { + identityReads += 1; + return new Map( + rows.map((row): [number, ProcessIdentityObservation] => { + const replacedAncestor = + row.pid === child.pid && ancestorGeneration !== "unavailable"; + if ( + !replacedAncestor && + (phase === "exited" || + (phase === "omitted" && row.pid !== detached.pid)) + ) + return [row.pid, { state: "absent", reason: "not-in-snapshot" }]; + if (row.pid !== child.pid) return [row.pid, nativeIdentity(row)]; + if (identityReads === 1) + return [ + row.pid, + ancestorGeneration === "unavailable" + ? { state: "unavailable", reason: "identity denied" } + : ancestorGeneration === "relation-unavailable-replaced" + ? { + ...nativeIdentity(row), + relation: { + state: "unavailable", + reason: "relation denied", + }, + } + : nativeIdentity(row), + ]; + // This unrelated relation belongs to a new or unproven generation. + // It cannot disprove the old ancestry of the surviving descendant. + return [ + row.pid, + { + ...nativeIdentity({ + ...row, + parentPid: 1, + processGroupId: 200, + }), + identity: "replacement-101", + }, + ]; + }), + ); + }, + signalGroup, + }; + + const lineage = await observeOwnedProcessLineage(ownership, host); + const retained = evidenceOwner.candidates; + phase = "omitted"; + // The original ancestor has exited, but its replacement and detached + // descendant stay alive. Only the descendant remains in this run's scope. + // Losing that descendant would falsely certify cleanup from an empty table. + await expect( + cleanupOwnedProcessGroup(ownership, host), + ).resolves.toMatchObject({ + cleaned: false, + failures: [ + expect.objectContaining({ + pid: detached.pid, + reason: "environment-unreadable", + }), + ], + }); + expect(lineage.status).toBe("unavailable"); + expect(retained).toContainEqual( + expect.objectContaining({ + pid: detached.pid, + identity: "start-102", + applicability: "related-ownership-unresolved", + scopeEvidence: "unverified-table-relation", + }), + ); + if (ancestorGeneration !== "unavailable") + expect(retained.map(({ pid }) => pid)).not.toContain(child.pid); + + phase = "exited"; + await expect(cleanupOwnedProcessGroup(ownership, host)).resolves.toEqual({ + cleaned: true, + signaled: false, + }); + expect(evidenceOwner.candidates).toEqual([]); + expect(signalGroup).not.toHaveBeenCalled(); + }, +); + +const exitedIdentity = ( + row: ProcessTableEntry, + exit: "absent" | "zombie" | "dead", +): ProcessIdentityObservation => + exit === "absent" + ? { state: "absent", reason: "not-in-snapshot" } + : { + ...nativeIdentity(row), + lifecycle: { state: "observed", lifecycle: exit }, + }; + +it("keeps an exiting descendant whose termination cannot be confirmed", async () => { + const evidenceOwner = new ProcessOwnershipEvidenceOwner(); + const ownership = { + runId: "run", + leaderPid: launcher.pid, + processGroupId: launcher.processGroupId, + sweepTokenOwnedProcesses: true as const, + evidenceOwner, + }; + let omitted = false; + let identityReads = 0; + const signalGroup = vi.fn(); + const host: ProcessOwnershipHost = { + listProcesses: async () => (omitted ? [] : [launcher, child]), + environment: async () => ({ REA_PROCESS_RUN_ID: "run" }), + processIdentities: async (rows) => { + identityReads += 1; + return new Map( + rows.map((row): [number, ProcessIdentityObservation] => [ + row.pid, + row.pid === launcher.pid + ? omitted + ? { state: "absent", reason: "not-in-snapshot" } + : nativeIdentity(row) + : identityReads === 1 + ? { + ...nativeIdentity(row), + lifecycle: { state: "observed", lifecycle: "exiting" }, + } + : { state: "unavailable", reason: "identity denied" }, + ]), + ); + }, + signalGroup, + }; + + await expect( + observeOwnedProcessLineage(ownership, host), + ).resolves.toMatchObject({ + status: "unavailable", + }); + omitted = true; + await expect( + cleanupOwnedProcessGroup(ownership, host), + ).resolves.toMatchObject({ + cleaned: false, + failures: [ + expect.objectContaining({ + pid: child.pid, + reason: "process-identity-unavailable", + }), + ], + }); + expect(evidenceOwner.candidates).toEqual([ + expect.objectContaining({ pid: child.pid, identity: undefined }), + ]); + expect(signalGroup).not.toHaveBeenCalled(); +}); + +it.each( + (["absent", "zombie", "dead"] as const).flatMap((exit) => + ( + [ + { target: "launcher", retained: false, reparented: false }, + { target: "launcher", retained: true, reparented: false }, + { target: "descendant", retained: false, reparented: false }, + { target: "descendant", retained: true, reparented: false }, + { target: "descendant", retained: true, reparented: true }, + ] as const + ).map((scenario) => ({ ...scenario, exit })), + ), +)( + "does not retain a reused PID after native $exit of a $target (previously retained: $retained, reparented: $reparented)", + async ({ target, retained, reparented, exit }) => { + const stale = target === "launcher" ? launcher : child; + const replacement = { ...stale, parentPid: 1, processGroupId: 200 }; + const evidenceOwner = new ProcessOwnershipEvidenceOwner(); + const ownership = { + runId: "run", + leaderPid: launcher.pid, + processGroupId: launcher.processGroupId, + sweepTokenOwnedProcesses: true as const, + evidenceOwner, + }; + let phase: "unavailable" | "absent" | "reused" = retained + ? "unavailable" + : "absent"; + let identityReads = 0; + const tokenReads: number[] = []; + const signalGroup = vi.fn(); + const host: ProcessOwnershipHost = { + listProcesses: async () => + phase === "reused" + ? [] + : [launcher, phase === "absent" && reparented ? replacement : child], + environment: async (pid) => { + tokenReads.push(pid); + return { REA_PROCESS_RUN_ID: pid === stale.pid ? "foreign" : "run" }; + }, + processIdentities: async (rows) => { + identityReads += 1; + return new Map( + rows.map((row): [number, ProcessIdentityObservation] => { + if (row.pid !== stale.pid) + return [ + row.pid, + phase === "reused" + ? { state: "absent", reason: "not-in-snapshot" } + : nativeIdentity(row), + ]; + if (phase === "unavailable") + return [ + row.pid, + { state: "unavailable", reason: "identity denied" }, + ]; + if (phase === "absent" && identityReads === 1) + return [row.pid, exitedIdentity(row, exit)]; + return [ + row.pid, + { ...nativeIdentity(replacement), identity: "replacement" }, + ]; + }), + ); + }, + signalGroup, + }; + + if (retained) { + await observeOwnedProcessLineage(ownership, host); + expect(evidenceOwner.candidates).toContainEqual( + expect.objectContaining({ pid: stale.pid, identity: undefined }), + ); + phase = "absent"; + identityReads = 0; + tokenReads.length = 0; + } + const lineage = await observeOwnedProcessLineage(ownership, host); + const retainedAfterAbsence = evidenceOwner.candidates; + const tokenReadsAfterAbsence = [...tokenReads]; + phase = "reused"; + + // The replacement remains alive with a foreign token after table omission. + // Cleanup must not wait for its exit or acquire authority from the stale row. + await expect(cleanupOwnedProcessGroup(ownership, host)).resolves.toEqual({ + cleaned: true, + signaled: false, + }); + expect(retainedAfterAbsence.map(({ pid }) => pid)).not.toContain(stale.pid); + expect(tokenReadsAfterAbsence).not.toContain(stale.pid); + expect(lineage.status).toBe( + target === "launcher" ? "unavailable" : "verified", + ); + expect(evidenceOwner.candidates).toEqual([]); + expect(signalGroup).not.toHaveBeenCalled(); + }, +); + +it.each(["absent", "zombie", "dead"] as const)( + "retains an omitted descendant through a native %s ancestor whose PID is reused", + async (exit) => { + const detached = { ...grandchild, processGroupId: grandchild.pid }; + const replacement = { ...child, parentPid: 1, processGroupId: 200 }; + const evidenceOwner = new ProcessOwnershipEvidenceOwner(); + const ownership = { + runId: "run", + leaderPid: launcher.pid, + processGroupId: launcher.processGroupId, + sweepTokenOwnedProcesses: true as const, + evidenceOwner, + }; + let phase: "observing" | "omitted" | "exited" = "observing"; + let identityReads = 0; + const signalGroup = vi.fn(); + const host: ProcessOwnershipHost = { + listProcesses: async () => + phase === "observing" ? [launcher, child, detached] : [replacement], + environment: async () => ({}), + runTokens: async (rows) => + new Map( + rows.map(({ pid }) => [ + pid, + pid === detached.pid + ? { state: "unavailable" as const, reason: "token denied" } + : { + state: "readable" as const, + runId: pid === launcher.pid ? "run" : "foreign", + }, + ]), + ), + processIdentities: async (rows) => { + identityReads += 1; + return new Map( + rows.map((row): [number, ProcessIdentityObservation] => { + if (row.pid === child.pid) + return [ + row.pid, + identityReads === 1 + ? exitedIdentity(row, exit) + : { ...nativeIdentity(replacement), identity: "replacement" }, + ]; + if ( + phase === "exited" || + (phase === "omitted" && row.pid === launcher.pid) + ) + return [row.pid, { state: "absent", reason: "not-in-snapshot" }]; + return [row.pid, nativeIdentity(row)]; + }), + ); + }, + signalGroup, + }; + + await expect( + observeOwnedProcessLineage(ownership, host), + ).resolves.toMatchObject({ + status: "unavailable", + }); + const retained = evidenceOwner.candidates; + phase = "omitted"; + await expect( + cleanupOwnedProcessGroup(ownership, host), + ).resolves.toMatchObject({ + cleaned: false, + failures: [ + expect.objectContaining({ + pid: detached.pid, + reason: "environment-unreadable", + }), + ], + }); + expect(retained).toContainEqual( + expect.objectContaining({ + pid: detached.pid, + identity: "start-102", + applicability: "related-ownership-unresolved", + scopeEvidence: "unverified-table-relation", + }), + ); + expect(retained.map(({ pid }) => pid)).not.toContain(child.pid); + + phase = "exited"; + await expect(cleanupOwnedProcessGroup(ownership, host)).resolves.toEqual({ + cleaned: true, + signaled: false, + }); + expect(evidenceOwner.candidates).toEqual([]); + expect(signalGroup).not.toHaveBeenCalled(); + }, +); diff --git a/src/process/ProcessOwnershipEvidence.ts b/src/process/ProcessOwnershipEvidence.ts index 10752d6b3b..4cbc8ffe91 100644 --- a/src/process/ProcessOwnershipEvidence.ts +++ b/src/process/ProcessOwnershipEvidence.ts @@ -23,6 +23,7 @@ export interface RetainedProcessOwnershipCandidate { | "capture-leader-pid" | "captured-process-group-id" | "native-identity-unresolved" + | "unverified-table-relation" | "previously-related-generation"; readonly token: ProcessRunTokenObservation; readonly relation: @@ -81,6 +82,15 @@ export class ProcessOwnershipEvidenceOwner { readonly processGroupIds: ReadonlySet; /** First identity/token/identity result before bounded token-only retries. */ readonly scopeFacts?: readonly ProcessOwnershipFact[]; + /** Native exit settled before candidate selection; these PIDs have no facts. */ + readonly nativeExitedPids?: ReadonlySet; + /** False disables disproven capture anchors without discarding token-owned ancestry. */ + readonly allowCapturedScope?: boolean; + /** Caller-selected lineage links preserve uncertain scope, never ownership proof. */ + readonly unverifiedLineageRelations?: ReadonlyMap< + number, + Pick + >; }, ): readonly RetainedProcessOwnershipCandidate[] { this.#snapshotCount += 1; @@ -126,33 +136,58 @@ export class ProcessOwnershipEvidenceOwner { "run-token" | "previously-related-generation" > | undefined => { - const identity = generations.get(pid); - if (identity === undefined) return undefined; - const relation = - identity.relation?.state === "observed" ? identity.relation : undefined; + let unverifiedRelation = false; + const relationFor = (candidatePid: number) => { + const identity = generations.get(candidatePid); + if (identity?.relation?.state === "observed") return identity.relation; + const fallback = options.unverifiedLineageRelations?.get(candidatePid); + if (fallback === undefined) return undefined; + const current = sameGeneration.get(candidatePid); + // Only a same-generation native relation can disprove the earlier + // scope. A replacement or unproven generation must not sever the + // retained ancestry of a surviving descendant. + if (current?.relation?.state === "observed") { + if (identity === undefined) unverifiedRelation = true; + return current.relation; + } + unverifiedRelation = true; + return fallback; + }; + const scoped = ( + evidence: + | "captured-process-group-id" + | "capture-leader-pid" + | "current-token-owned-parent", + ) => + unverifiedRelation ? ("unverified-table-relation" as const) : evidence; + const relation = relationFor(pid); if (relation === undefined) return undefined; - if (options.processGroupIds.has(relation.processGroupId)) - return "captured-process-group-id"; + if ( + options.allowCapturedScope !== false && + options.processGroupIds.has(relation.processGroupId) + ) + return scoped("captured-process-group-id"); const visited = new Set([pid]); let parentPid = relation.parentPid; while (!visited.has(parentPid)) { const parent = generations.get(parentPid); - if (parent === undefined) { - return parentPid === options.leaderPid - ? "capture-leader-pid" - : undefined; - } const previousParent = this.#candidates.get(parentPid); if ( - tokenOwners.has(parentPid) || - (previousParent?.applicability === "token-verified" && - parent.identity === previousParent.identity) + parent !== undefined && + (tokenOwners.has(parentPid) || + (previousParent?.applicability === "token-verified" && + parent.identity === previousParent.identity)) ) - return "current-token-owned-parent"; - if (parentPid === options.leaderPid) return "capture-leader-pid"; + return scoped("current-token-owned-parent"); + if ( + options.allowCapturedScope !== false && + parentPid === options.leaderPid + ) + return scoped("capture-leader-pid"); visited.add(parentPid); - if (parent.relation?.state !== "observed") return undefined; - parentPid = parent.relation.parentPid; + const parentRelation = relationFor(parentPid); + if (parentRelation === undefined) return undefined; + parentPid = parentRelation.parentPid; } return undefined; }; @@ -215,19 +250,27 @@ export class ProcessOwnershipEvidenceOwner { }; for (const [pid, previous] of this.#candidates) { + // A bounded native exit also retires an unresolved prior generation, + // even when the caller excludes this PID from subsequent token reads. + if (options.nativeExitedPids?.has(pid)) { + this.#candidates.delete(pid); + retiredPids.add(pid); + continue; + } const fact = factsByPid.get(pid); if (fact === undefined) continue; // Retire only this generation's current ledger entry. Darwin's // `not-in-snapshot` and Linux's corroborated ENOENT/ESRCH facts are // bounded observations, not claims about later PID reuse or global - // liveness. + // liveness. A bracketed replacement also settles an identity-less + // candidate: its earlier lifetime has ended, regardless of relation access. if ( fact.state === "absent" || + fact.state === "replaced" || (previous.identity !== undefined && fact.identityAfter.state === "readable" && fact.identityAfter.identity !== previous.identity) || - (fact.state === "same-generation" && - processIdentityHasExited(fact.identityAfter)) + processIdentityHasExited(fact.identityAfter) ) { this.#candidates.delete(pid); retiredPids.add(pid); @@ -286,16 +329,30 @@ export class ProcessOwnershipEvidenceOwner { for (const fact of facts) { const pid = fact.process.pid; + const matched = + fact.token.state === "readable" && fact.token.runId === options.runId; + const unresolvedReplacement = fact.state === "replaced" && matched; + // A replacement's token cannot be attributed to either generation. + // A matching token preserves uncertainty for another native observation, + // without carrying the retired generation's identity or signal authority. if ( this.#candidates.has(pid) || - (retiredPids.has(pid) && fact.state !== "same-generation") || + (retiredPids.has(pid) && + fact.state !== "same-generation" && + !unresolvedReplacement) || fact.state === "absent" || - (fact.state === "same-generation" && - processIdentityHasExited(fact.identityAfter)) + (fact.state === "replaced" && !matched) || + processIdentityHasExited(fact.identityAfter) ) continue; const identity = fact.identityAfter; if (fact.state !== "same-generation" || identity.state !== "readable") { + if ( + options.allowCapturedScope === false && + !matched && + related(pid) === undefined + ) + continue; this.#candidates.set(pid, { pid, identity: undefined, @@ -311,8 +368,6 @@ export class ProcessOwnershipEvidenceOwner { uncertainPids.add(pid); continue; } - const matched = - fact.token.state === "readable" && fact.token.runId === options.runId; const unresolvedScope = matched ? undefined : related(pid); if (!matched && unresolvedScope === undefined) continue; const relation = diff --git a/src/process/ProcessOwnershipFacts.test.ts b/src/process/ProcessOwnershipFacts.test.ts index 90616f9788..f7c80ea5a8 100644 --- a/src/process/ProcessOwnershipFacts.test.ts +++ b/src/process/ProcessOwnershipFacts.test.ts @@ -21,7 +21,7 @@ const row: ProcessTableEntry = { const identity = ( value: string, - lifecycle: "non-zombie" | "zombie" = "non-zombie", + lifecycle: "non-zombie" | "zombie" | "dead" = "non-zombie", relation: ProcessNativeRelationObservation = { state: "observed", parentPid: row.parentPid, @@ -80,18 +80,184 @@ it("binds owned, missing, and foreign tokens only to the same identity generatio } }); -it("retains a matching token as partial evidence when identity changes", async () => { +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 [fact] = await readProcessOwnershipFacts([row], host); - expect(fact).toMatchObject({ + 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); diff --git a/src/process/ProcessOwnershipObservation.ts b/src/process/ProcessOwnershipObservation.ts index 17fbe6de37..15023e45fc 100644 --- a/src/process/ProcessOwnershipObservation.ts +++ b/src/process/ProcessOwnershipObservation.ts @@ -348,39 +348,45 @@ const observeNativeOwnedProcessLineage = async ( ): Promise => { const initialIdentities = await readNativeProcessIdentityMap(processes, host); const launcher = processes.find(({ pid }) => pid === ownership.leaderPid); - if (launcher === undefined) - return unavailableLineage(ownership, "owned launcher is not live"); - const launcherIdentity = initialIdentities.get(launcher.pid); - if (launcherIdentity?.state === "absent") - return unavailableLineage(ownership, "owned launcher is not live"); - if (launcherIdentity?.state !== "readable") - return unavailableLineage( - ownership, - `owned launcher identity could not be read: ${launcherIdentity?.reason ?? "identity observation was omitted"}`, - ); - if (processIdentityHasExited(launcherIdentity)) - return unavailableLineage(ownership, "owned launcher is not live"); - const nativeLauncher = processWithNativeRelation(launcher, launcherIdentity); - if (nativeLauncher === undefined) - return unavailableLineage( - ownership, - "owned launcher native parent/group relation was unavailable", - ); - const identityFailure = launcherIdentityFailure(nativeLauncher, ownership); - if (identityFailure !== null) - return unavailableLineage(ownership, identityFailure); - - const nativeProcesses = processes.flatMap((process) => { + const launcherIdentity = + launcher === undefined ? undefined : initialIdentities.get(launcher.pid); + const nativeLauncher = + launcher !== undefined && + launcherIdentity?.state === "readable" && + !processIdentityHasExited(launcherIdentity) + ? processWithNativeRelation(launcher, launcherIdentity) + : undefined; + const identityFailure = + nativeLauncher === undefined + ? null + : launcherIdentityFailure(nativeLauncher, ownership); + // Missing native facts leave table ancestry as candidate scope only. Keep + // unresolved and exited intermediates as discovery bridges to descendants; + // only validated live native facts may authorize the published lineage. + const candidateProcesses = processes.map((process) => { const identity = initialIdentities.get(process.pid); - if (processIdentityHasExited(identity)) return []; const nativeProcess = identity?.state === "readable" ? processWithNativeRelation(process, identity) : undefined; - return nativeProcess === undefined ? [] : [nativeProcess]; + return nativeProcess ?? process; }); - const descendants = descendantsOf([ownership.leaderPid], nativeProcesses); - const candidates = [nativeLauncher, ...descendants]; + const descendants = descendantsOf([ownership.leaderPid], candidateProcesses); + const lineageRows = [ + ...(launcher === undefined ? [] : [launcher]), + ...descendants, + ]; + // A retained candidate may have moved outside the current launcher lineage. + const nativeExitedPids = new Set( + processes + .filter(({ pid }) => processIdentityHasExited(initialIdentities.get(pid))) + .map(({ pid }) => pid), + ); + // A native terminal fact settles the old candidate. Keep its ancestry link, + // but do not enroll a replacement that appears during later token reads. + const candidates = lineageRows.filter( + ({ pid }) => !nativeExitedPids.has(pid), + ); const facts = await readProcessOwnershipFacts(candidates, host, undefined, { identityBefore: initialIdentities, }); @@ -396,7 +402,38 @@ const observeNativeOwnedProcessLineage = async ( ownership.processGroupId, ...(ownership.sampledProcessGroupIds ?? []), ]), + // A readable mismatching launcher disproves the captured anchors. Keep + // independent token evidence, existing candidates, and unknown/exit bridges. + // Later same-generation movement cannot erase valid initial scope. + allowCapturedScope: identityFailure === null, + nativeExitedPids, + unverifiedLineageRelations: new Map( + lineageRows.map(({ pid, parentPid, processGroupId }) => [ + pid, + { parentPid, processGroupId }, + ]), + ), }); + // A launcher failure prevents publication, but must not discard partial + // ownership evidence already observed for this run's descendants. + if (launcher === undefined) + return unavailableLineage(ownership, "owned launcher is not live"); + if (launcherIdentity?.state === "absent") + return unavailableLineage(ownership, "owned launcher is not live"); + if (launcherIdentity?.state !== "readable") + return unavailableLineage( + ownership, + `owned launcher identity could not be read: ${launcherIdentity?.reason ?? "identity observation was omitted"}`, + ); + if (processIdentityHasExited(launcherIdentity)) + return unavailableLineage(ownership, "owned launcher is not live"); + if (nativeLauncher === undefined) + return unavailableLineage( + ownership, + "owned launcher native parent/group relation was unavailable", + ); + if (identityFailure !== null) + return unavailableLineage(ownership, identityFailure); const factsByPid = new Map(facts.map((fact) => [fact.process.pid, fact])); const verifiedDescendants: ProcessTableEntry[] = []; let currentLauncher = nativeLauncher; diff --git a/tests/acceptance/webModuleCli.test.ts b/tests/acceptance/webModuleCli.test.ts index 54463ccb31..93789a9110 100644 --- a/tests/acceptance/webModuleCli.test.ts +++ b/tests/acceptance/webModuleCli.test.ts @@ -32,7 +32,11 @@ cliTest( }; const cliResponse = await cli.run({ arguments: ["trace-web-module-imports", manifestPath, "0", "--json"], - environment, + environment: { + ...environment, + // This single CLI invocation cannot reuse a newly written cache. + NODE_DISABLE_COMPILE_CACHE: "1", + }, timeoutMs: 15000, }); expect(cliResponse.exitCode, cliResponse.stdout + cliResponse.stderr).toBe( diff --git a/tests/boundary/process/processLineageRetention.test.ts b/tests/boundary/process/processLineageRetention.test.ts new file mode 100644 index 0000000000..423fa066a3 --- /dev/null +++ b/tests/boundary/process/processLineageRetention.test.ts @@ -0,0 +1,160 @@ +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { Writable } from "node:stream"; +import { expect, it } from "vitest"; + +import { + cleanupOwnedProcessGroup, + type PersistentOwnedProcessGroup, + type ProcessOwnershipHost, +} from "../../../src/process/ProcessOwnership.js"; +import { ProcessOwnershipEvidenceOwner } from "../../../src/process/ProcessOwnershipEvidence.js"; +import { + createSystemProcessOwnershipHost, + observeOwnedProcessLineage, +} from "../../../src/process/ProcessOwnershipObservation.js"; +import { waitForDetachedProviderChild } from "../../fixtures/providerProcess.js"; + +const descendantSource = ` +process.stdin.resume(); +process.stdin.once('end', () => process.exit(0)); +process.stdout.write('ready\\n'); +`; + +const launcherSource = ` +const { spawn } = require('node:child_process'); +const child = spawn(process.execPath, ['-e', ${JSON.stringify(descendantSource)}], { + detached: true, + stdio: [3, 'pipe', process.stderr], +}); +child.once('error', () => process.exit(1)); +child.stdout.once('data', () => { + process.stdout.write('ready:' + child.pid + '\\n'); +}); +child.unref(); +process.stdin.once('data', () => process.exit(0)); +`; + +it.skipIf(process.platform !== "linux" && process.platform !== "darwin")( + "retains a native descendant after identity denial, launcher exit and a later table omission", + async () => { + const native = createSystemProcessOwnershipHost( + process.platform, + process.env, + ); + try { + await native.prepare?.(); + const captureBaseline = await native.captureBaseline?.(); + const runId = randomUUID(); + const launcher = spawn(process.execPath, ["-e", launcherSource], { + detached: true, + env: { ...process.env, REA_PROCESS_RUN_ID: runId }, + // FD3 is the descendant's lifetime barrier; launcher stdin only exits + // the launcher. Its exit must not close the descendant's inherited pipe. + stdio: ["pipe", "pipe", "pipe", "pipe"], + }); + const launcherExited = new Promise((resolve, reject) => { + launcher.once("exit", () => resolve()); + launcher.once("error", reject); + }); + launcher.stderr?.resume(); + const barrier = launcher.stdio[3]; + let ownership: PersistentOwnedProcessGroup | undefined; + try { + if (launcher.pid === undefined || !(barrier instanceof Writable)) + throw new Error("Expected an owned launcher and descendant barrier"); + ownership = { + runId, + leaderPid: launcher.pid, + processGroupId: launcher.pid, + expectedParentPid: process.pid, + sweepTokenOwnedProcesses: true, + evidenceOwner: new ProcessOwnershipEvidenceOwner(), + ...(captureBaseline === undefined ? {} : { captureBaseline }), + }; + const descendantPid = await waitForDetachedProviderChild(launcher); + const descendant = (await native.listProcesses()).find( + ({ pid }) => pid === descendantPid, + ); + expect(descendant).toMatchObject({ + parentPid: launcher.pid, + processGroupId: descendantPid, + }); + if (descendant === undefined || native.processIdentities === undefined) + throw new Error("Expected the real native descendant identity owner"); + + const deniedIdentityHost: ProcessOwnershipHost = { + ...native, + processIdentities: async (rows, signal, effectiveUidPids) => { + const identities = new Map( + await native.processIdentities?.(rows, signal, effectiveUidPids), + ); + if (rows.some(({ pid }) => pid === descendantPid)) + identities.set(descendantPid, { + state: "unavailable", + reason: "controlled descendant identity denial", + }); + return identities; + }, + }; + const lineage = await observeOwnedProcessLineage( + ownership, + deniedIdentityHost, + ); + const retainedPids = ownership.evidenceOwner.candidates.map( + ({ pid }) => pid, + ); + + launcher.stdin?.end("exit\n"); + await launcherExited; + expect(launcher.exitCode).toBe(0); + expect( + (await native.processIdentities([descendant])).get(descendantPid), + ).toMatchObject({ + state: "readable", + lifecycle: { state: "observed", lifecycle: "non-zombie" }, + }); + + const omittedHost: ProcessOwnershipHost = { + ...native, + listProcesses: async (signal) => + (await native.listProcesses(signal)).filter( + ({ pid }) => pid !== descendantPid, + ), + }; + const omitted = await cleanupOwnedProcessGroup(ownership, omittedHost); + // The incomplete table cannot authorize signaling. It also cannot erase + // a retained native candidate and certify cleanup while it is alive. + expect(omitted).toMatchObject({ cleaned: false }); + expect(lineage.status).toBe("unavailable"); + expect(retainedPids).toContain(descendantPid); + expect( + (await native.processIdentities([descendant])).get(descendantPid), + ).toMatchObject({ + state: "readable", + lifecycle: { state: "observed", lifecycle: "non-zombie" }, + }); + + // Restore the complete native table and retry the same retained owner. + // The run token, native generation and current group authorize cleanup. + await expect( + cleanupOwnedProcessGroup(ownership, native), + ).resolves.toMatchObject({ cleaned: true, signaled: true }); + expect(ownership.evidenceOwner.candidates).toEqual([]); + } finally { + if (barrier instanceof Writable) barrier.end(); + if (launcher.exitCode === null && launcher.signalCode === null) + launcher.stdin?.end("exit\n"); + await launcherExited; + if (ownership !== undefined) + expect( + await cleanupOwnedProcessGroup(ownership, native), + ).toMatchObject({ + cleaned: true, + }); + } + } finally { + await native.close?.(); + } + }, +);