Merge commit 'd7e2c58549' into brennanb2025/claude-sdk-ready-mainsync

This commit is contained in:
Merge Sim
2026-09-03 17:25:30 -07:00
6 changed files with 196 additions and 24 deletions
@@ -24,19 +24,17 @@ function windowsSnapshot(): WindowsDescendantSnapshot {
}
describe('Claude child root identity', () => {
it('keeps a retained row boundary when a refresh observes no new descendants', () => {
it('advances a re-walked row boundary to the refresh that re-derived it', () => {
const previous = posixSnapshot(1_700_000_000_900)
const next = posixSnapshot(1_700_000_002_100)
// Every row carries the refresh boundary, so no per-pid map is needed.
expect(
mergeClaudeCapturedTrees(
{ platform: 'posix', tree: previous },
{ platform: 'posix', tree: next }
)
).toEqual({
platform: 'posix',
tree: { ...next, capturedAtMsByPid: { '200': previous.capturedAtMs } }
})
).toEqual({ platform: 'posix', tree: next })
})
it('keeps the descendant verdict when a POSIX root probe is unavailable', async () => {
@@ -513,17 +513,20 @@ describe('claude child tree reaper', () => {
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
})
it('keeps the original capture boundary for retained POSIX rows', async () => {
it('advances a re-walked POSIX row boundary but not one the refresh missed', async () => {
const child = mockChild()
const unseen = { pid: 4245, ppid: 424242, pgid: 1, startedAt: 'Mon Jan 1 00:00:00 2026' }
const base = snapshotOf(4243)
const first = {
...snapshotOf(4243),
capturedAtMs: 1_700_000_000_900
...base,
capturedAtMs: 1_700_000_000_900,
descendants: [...base.descendants, unseen]
}
const refreshed = {
...first,
...base,
capturedAtMs: 1_700_000_002_100,
descendants: [
...first.descendants,
...base.descendants,
{
pid: 4244,
ppid: 424242,
@@ -546,11 +549,13 @@ describe('claude child tree reaper', () => {
expect(terminateDescendants).toHaveBeenCalledWith({
...refreshed,
// The retained 4243 row was first observed in the earlier displayed
// second. Its per-row boundary must not advance with the refresh.
descendants: [...base.descendants, unseen, refreshed.descendants[1]],
capturedAtMsByPid: {
'4243': first.capturedAtMs,
'4244': refreshed.capturedAtMs
// Re-derived from the live root by this walk, so proved ours again at it.
'4243': refreshed.capturedAtMs,
'4244': refreshed.capturedAtMs,
// Absent from the refresh: nothing re-proved it, so it keeps its own.
'4245': first.capturedAtMs
}
})
})
@@ -167,17 +167,16 @@ describe('Claude root kill fallback', () => {
expect(child.kill).not.toHaveBeenCalled()
})
it('chains per-pid Windows boundaries across a second merge', async () => {
const first = windowsSnapshot(1_000)
it('chains the boundary of a row dropped from later walks', () => {
const first: WindowsDescendantSnapshot = { ...windowsSnapshot(1_000) }
const second: WindowsDescendantSnapshot = {
...windowsSnapshot(2_000),
descendants: [
{ pid: 4243, creationTimeMs: 1_700_000_000_000 },
{ pid: 4244, creationTimeMs: 1_700_000_000_002 }
]
descendants: [{ pid: 4244, creationTimeMs: 1_700_000_000_002 }]
}
const third: WindowsDescendantSnapshot = { ...second, capturedAtMs: 3_000 }
// 4243 is absent from both refreshes, so it keeps the boundary of the walk
// that did see it rather than inheriting the latest scan's scalar.
const merged = mergeClaudeCapturedTrees(
{ platform: 'win32', tree: first },
{ platform: 'win32', tree: second }
@@ -185,6 +184,6 @@ describe('Claude root kill fallback', () => {
expect(merged?.tree.capturedAtMsByPid).toEqual({ '4243': 1_000, '4244': 2_000 })
const rechained = mergeClaudeCapturedTrees(merged!, { platform: 'win32', tree: third })
expect(rechained?.tree.capturedAtMsByPid).toEqual({ '4243': 1_000, '4244': 2_000 })
expect(rechained?.tree.capturedAtMsByPid).toEqual({ '4243': 1_000, '4244': 3_000 })
})
})
@@ -19,6 +19,10 @@ export async function proveClaudeChildExitWithReaper(
const tree = input.tree ?? createTree()
// Arm before stdin closes: only a live root can identify its descendants.
await tree.capture()
// And re-walk it here, while the root certainly still lives: a descendant the
// arm first saw inside its own birth second is otherwise never eligible for a
// forced sweep, and the root may exit before any later walk gets the chance.
await tree.refresh?.()
try {
input.child.stdin?.end()
} catch {
@@ -10,6 +10,9 @@ export type ClaudeCapturedTree =
* Process-table reads are not atomic: a refresh can omit a still-live row, but
* it can also observe a new process after the old row exited. Retain rows absent
* from the refresh, but reject a PID whose identity changed between reads.
*
* Each row also carries the instant its membership of the tree was last proved,
* which is what the forced sweep fences on.
*/
function mergeRowsByPid<Row extends { pid: number }>(
previous: readonly Row[],
@@ -34,9 +37,11 @@ function mergeRowsByPid<Row extends { pid: number }>(
if (prior && !sameIdentity(prior, row)) {
return null
}
if (!prior) {
capturedAtMsByPid[String(row.pid)] = nextBoundary(row)
}
// Why a retained row may take the later boundary: this walk re-derived it
// from the live root by ppid, which proves the process holding that pid is
// ours at this instant without appealing to its start time. A row only the
// earlier walk saw keeps the earlier boundary -- nothing re-proved it.
capturedAtMsByPid[String(row.pid)] = nextBoundary(row)
merged.set(row.pid, row)
}
const boundaries = Object.values(capturedAtMsByPid)
@@ -0,0 +1,161 @@
import { describe, expect, it, vi } from 'vitest'
import type { DescendantTreeVerdict } from '../pty-descendant-exit-verification'
import { terminateDescendantSnapshotWithVerdict } from '../pty-descendant-exit-verification'
import {
collectDescendantRows,
type DescendantSnapshot,
type ProcessTableRow
} from '../pty-descendant-termination'
import { createClaudeChildTreeReaper, proveClaudeChildExit } from './claude-agent-sdk-exit-proof'
const ROOT_PID = 500
const ORCA_PGID = 400
const ROOT_STARTED_AT = 'Thu Sep 3 16:37:20 2026'
const BIRTH_SECOND = 'Thu Sep 3 16:37:40 2026'
const BIRTH_MS = Date.parse(BIRTH_SECOND)
/** The measured shape: ten MCP-like children forked inside second :40. */
const DESCENDANT_PIDS = [600, 601, 602, 603, 604, 605, 606, 607, 608, 609]
function tableRows(descendantStartedAt: string): ProcessTableRow[] {
return [
{ pid: ROOT_PID, ppid: 1, pgid: ORCA_PGID, startedAt: ROOT_STARTED_AT },
...DESCENDANT_PIDS.map((pid) => ({
pid,
ppid: ROOT_PID,
pgid: ORCA_PGID,
startedAt: descendantStartedAt
}))
]
}
/** A real ppid walk from the root, exactly as production captures one. */
function walk(capturedAtMs: number, descendantStartedAt = BIRTH_SECOND): DescendantSnapshot {
return collectDescendantRows(ROOT_PID, tableRows(descendantStartedAt), capturedAtMs)
}
/**
* Drives the real verifier against a process table where every descendant traps
* SIGTERM, so only a forced sweep can end them.
*/
function sigtermResistantVerifier(sendSignal: (pid: number, signal: NodeJS.Signals) => void) {
return (snapshot: DescendantSnapshot) =>
terminateDescendantSnapshotWithVerdict(snapshot, {
requireIdentityBeforeSignal: true,
graceMs: 0,
verifyMs: 120,
sendSignal,
readTable: async () => ({ rows: tableRows(BIRTH_SECOND), capturedAtMs: Date.now() })
})
}
function killedPids(calls: [number, NodeJS.Signals][]): number[] {
return calls.flatMap(([pid, signal]) => (signal === 'SIGKILL' ? [pid] : []))
}
function signalledPids(calls: [number, NodeJS.Signals][]): number[] {
return calls.flatMap(([pid, signal]) => (signal === 'SIGTERM' ? [pid] : []))
}
async function sweep(captures: DescendantSnapshot[]): Promise<[number, NodeJS.Signals][]> {
const calls: [number, NodeJS.Signals][] = []
const captureDescendants = vi.fn()
for (const capture of captures) {
captureDescendants.mockResolvedValueOnce(capture)
}
const tree = createClaudeChildTreeReaper(
{ pid: ROOT_PID, kill: vi.fn(() => true) },
{
platform: 'linux',
exited: () => false,
captureDescendants,
terminateDescendants: sigtermResistantVerifier((pid, signal) => calls.push([pid, signal]))
}
)
// The close ladder's shape: arm, then re-walk the live root at the boundary.
await tree.capture()
await tree.refresh?.()
await tree.reap()
return calls
}
describe('Claude descendant forced-sweep boundary', () => {
it('escalates descendants first seen in their own birth second', async () => {
// Arm lands inside second :40, the same second the children were forked in;
// the close-boundary walk re-derives them from the live root in second :41.
const calls = await sweep([walk(BIRTH_MS + 231), walk(BIRTH_MS + 1_200)])
expect(signalledPids(calls)).toEqual(DESCENDANT_PIDS)
expect(killedPids(calls)).toEqual(DESCENDANT_PIDS)
})
it('still escalates descendants born before the capture that first saw them', async () => {
const earlier = 'Thu Sep 3 16:37:17 2026'
const calls: [number, NodeJS.Signals][] = []
const tree = createClaudeChildTreeReaper(
{ pid: ROOT_PID, kill: vi.fn(() => true) },
{
platform: 'linux',
exited: () => false,
captureDescendants: vi.fn(async () =>
collectDescendantRows(ROOT_PID, tableRows(earlier), BIRTH_MS + 231)
),
terminateDescendants: (snapshot) =>
terminateDescendantSnapshotWithVerdict(snapshot, {
requireIdentityBeforeSignal: true,
graceMs: 0,
verifyMs: 120,
sendSignal: (pid, signal) => calls.push([pid, signal]),
readTable: async () => ({ rows: tableRows(earlier), capturedAtMs: Date.now() })
})
}
)
await tree.capture()
await tree.reap()
expect(killedPids(calls)).toEqual(DESCENDANT_PIDS)
})
it('withholds the sweep while no walk has re-proved the rows in a later second', async () => {
// Both walks land inside the birth second: nothing has disambiguated the
// rows, so the forced sweep must still stand down.
const calls = await sweep([walk(BIRTH_MS + 231), walk(BIRTH_MS + 640)])
expect(signalledPids(calls)).toEqual(DESCENDANT_PIDS)
expect(killedPids(calls)).toEqual([])
})
})
describe('Claude close-boundary re-walk', () => {
it('re-walks the live root before stdin closes, not only after the grace window', async () => {
// The measured teardown: the root leaves on its own inside the grace window,
// so the post-timeout refresh never runs and this is the last walk that can
// happen while the root is still there to be walked from.
const order: string[] = []
const tree = {
capture: vi.fn(async () => {
order.push('capture')
}),
refresh: vi.fn(async () => {
order.push('refresh')
}),
reap: vi.fn(async (): Promise<DescendantTreeVerdict> => {
order.push('reap')
return 'exited'
}),
get treeVerdict(): DescendantTreeVerdict {
return 'unverifiable'
}
}
const stdin = { end: vi.fn(() => order.push('stdin-end')) }
await proveClaudeChildExit({
child: { pid: ROOT_PID, kill: vi.fn(() => true), stdin } as never,
exitPromise: Promise.resolve(),
exited: () => true,
tree
})
expect(order).toEqual(['capture', 'refresh', 'stdin-end', 'reap'])
})
})