From 02e705a3cc26d88634397ce80db60eab3644de2b Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Sun, 6 Sep 2026 16:36:08 -0700 Subject: [PATCH] refactor(windows): share the walk's pid index in the stale-link filter Resolve parent links through the same index the descendant walk builds, so a table that repeats a pid answers both the same way, and drop the non-null assertion on the walk by keeping the "cannot see" null contract. Pin the two filter branches nothing exercised: the root surviving its own recycled ppid, and the root's start bounding a link whose claimed parent denied its creation time. --- ...ndows-descendant-exit-verification.test.ts | 41 +++++++++++++++++++ .../windows-descendant-exit-verification.ts | 25 ++++++++--- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/src/main/windows-descendant-exit-verification.test.ts b/src/main/windows-descendant-exit-verification.test.ts index fecf8a74a19..6dddfdb53a8 100644 --- a/src/main/windows-descendant-exit-verification.test.ts +++ b/src/main/windows-descendant-exit-verification.test.ts @@ -53,6 +53,47 @@ describe('captureWindowsDescendantSnapshot', () => { ]) }) + it('keeps the root when its own parent PID was reused by a newer process', async () => { + // The root's retained ppid now names a process created after it. Pruning the + // root drops the whole snapshot, so its own link is never evidence about it. + const captured = await captureWindowsDescendantSnapshot(100, { + readTable: async () => [ + { pid: 100, ppid: 900, creationTimeMs: 5 }, + { pid: 900, ppid: 1, creationTimeMs: 50 }, + { pid: 200, ppid: 100, creationTimeMs: 7 } + ], + now: () => 42 + }) + + expect(captured).toEqual({ + root: { pid: 100, creationTimeMs: 5 }, + descendants: [{ pid: 200, creationTimeMs: 7 }], + unidentifiedCount: 0, + capturedAtMs: 42 + }) + }) + + it('bounds a link by the root when the claimed parent denied its creation time', async () => { + // 300 has no creation time to compare a child against, so the root's start is + // the only bound left: 350 could be its child, 360 predates the tree entirely. + const captured = await captureWindowsDescendantSnapshot(100, { + readTable: async () => [ + { pid: 100, ppid: 1, creationTimeMs: 5 }, + { pid: 300, ppid: 100 }, + { pid: 350, ppid: 300, creationTimeMs: 9 }, + { pid: 360, ppid: 300, creationTimeMs: 2 } + ], + now: () => 42 + }) + + expect(captured).toEqual({ + root: { pid: 100, creationTimeMs: 5 }, + descendants: [{ pid: 350, creationTimeMs: 9 }], + unidentifiedCount: 1, + capturedAtMs: 42 + }) + }) + it('walks the whole subtree and keeps only rows a later read can re-identify', async () => { const captured = await captureWindowsDescendantSnapshot(100, { // 400 is a grandchild; 300 denied a creation-time query, so no later read diff --git a/src/main/windows-descendant-exit-verification.ts b/src/main/windows-descendant-exit-verification.ts index f6a74079f34..1a1945792ee 100644 --- a/src/main/windows-descendant-exit-verification.ts +++ b/src/main/windows-descendant-exit-verification.ts @@ -1,3 +1,4 @@ +import { getProcessTableIndex } from '../shared/process-table-index' import type { DescendantTreeVerdict } from './pty-descendant-exit-verification' import { windowsDescendantsFromRows } from './providers/windows-foreground-process-rows' import { readWindowsProcessTableFresh } from './windows/windows-process-table' @@ -69,15 +70,24 @@ export async function captureWindowsDescendantSnapshot( // One table read, not a walk plus an identity read: each is bounded in // seconds, and this runs inside the close ladder's budget. const table = await (deps.readTable ?? readWindowsProcessTableFresh)().catch(() => null) - const root = table?.find((row) => row.pid === rootPid) - if (!table || typeof root?.creationTimeMs !== 'number') { + if (!table) { + return null + } + // The same index the walk below builds, so a table that repeats a pid resolves + // a parent link to the row the walk will actually traverse. + const rowsByPid = getProcessTableIndex(table).byPid + const root = rowsByPid.get(rootPid) + if (typeof root?.creationTimeMs !== 'number') { return null } const rootCreationTimeMs = root.creationTimeMs - const creationTimes = new Map(table.map((row) => [row.pid, row.creationTimeMs])) - // Windows retains the original parent PID after exit; a reused PID is not ancestry. + // Windows keeps a process's original parent PID after that parent exits, so a + // reused PID is not ancestry: no real child predates the parent it claims, and + // the root's own start bounds the subtree when a parent denied its time. The + // root is never pruned by its own link -- its ppid can be recycled too, and a + // pruned root loses the snapshot outright. const currentRows = table.filter((row) => { - const parentCreationTimeMs = creationTimes.get(row.ppid) + const parentCreationTimeMs = rowsByPid.get(row.ppid)?.creationTimeMs return ( row.pid === rootPid || row.creationTimeMs === undefined || @@ -85,7 +95,10 @@ export async function captureWindowsDescendantSnapshot( (parentCreationTimeMs === undefined || row.creationTimeMs >= parentCreationTimeMs)) ) }) - const descendants = windowsDescendantsFromRows(currentRows, rootPid)! + const descendants = windowsDescendantsFromRows(currentRows, rootPid) + if (!descendants) { + return null + } return { root: { pid: root.pid, creationTimeMs: root.creationTimeMs }, descendants: descendants.flatMap((row) =>