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.
This commit is contained in:
Merge Sim
2026-09-06 16:36:08 -07:00
parent 933adf43d2
commit 02e705a3cc
2 changed files with 60 additions and 6 deletions
@@ -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
@@ -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) =>