mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 00:03:15 +00:00
Merge commit 'd7e2c58549' into brennanb2025/claude-sdk-ready-mainsync
This commit is contained in:
@@ -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'])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user