Merge pull request #3193 from deepseek-harness/perf/process-table-snapshot

fix(subprocess): read the process table once per terminal poll
This commit is contained in:
Yichen Jiang
2026-08-27 15:36:17 +08:00
committed by GitHub
12 changed files with 501 additions and 93 deletions
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.md
2026-08-27-process-table-snapshots.md: 27c364607ca1e03a926c309f26007477a8785636
2026-08-27-process-table-snapshots.zh.md: 9c5fe65bb83a0d04fe5639b3ffefcf377c3588ef
@@ -0,0 +1,71 @@
# Agent Note: Process-table snapshots replace per-question inspector reads
Status: implemented
English | [中文](2026-08-27-process-table-snapshots.zh.md)
## Problem
A terminal readiness poll asks the platform three questions: the shell's descendant tree, its POSIX session membership, and whether each tracked descendant is still running. When each question reads the process table independently, the poll's cost scales with the number of descendants the running command spawned.
On macOS each read is a `/bin/ps -axo` fork that parses the entire table — 14.33 ms for 795 processes on the measured host. `LocalTerminalHandle.inspectForeground()` reads the tree once and then asks liveness once per tracked descendant, so one poll costs N+1 table reads for N descendants. `dsh-terminal-bash` polls every 50 ms for up to 30 s, and `ProcessInspectorInternals.exec` is `execFileSync`, so each poll blocks the event loop for its full duration.
Measured by driving the production `MacProcessInspector` against a real process tree:
| tracked descendants | one poll | share of the 50 ms interval |
|---|---|---|
| 0 | 18.0 ms | 36% |
| 1 | 33.8 ms | 68% |
| 2 | 49.1 ms | 98% |
| 5 | 87.1 ms | 174% |
| 10 | 178.4 ms | 357% |
Any command spawning two or more children — a pipeline, `make`, `pnpm`, `git` — saturates the host event loop until it exits.
Teardown has the same structure. `signalProcess` fences each signal against PID reuse by asking liveness itself, so signalling N members costs N table reads.
## Decision
`ProcessInspector.snapshot()` returns a `ProcessSnapshot`, one observation of the process table that answers `tree(rootPid)`, `session(sessionId)`, and `alive(identity)`. It replaces the three per-question methods; the inspector's remaining surface is `foregroundPgid`, `isStdinWaiting`, `signalGroup`, and `signalProcess`.
Each caller captures one snapshot and answers every question of a single pass from it. `LocalTerminalHandle.descendants()` takes a snapshot, reads the tree and session from it, and filters survivors through the same `alive`, so a readiness poll costs one table read regardless of descendant count. `waitForMembers` captures a fresh snapshot per polling iteration, because its whole purpose is observing change.
Signalling does not share that observation. `ProcessInspector.isAlive(identity)` answers current state from the narrowest per-identity source a platform offers — one `/proc/<pid>/stat` read on Linux, one `ps` table on macOS, one process-handle check on Windows — and `signalProcess` takes that fence immediately before delivering the signal. An observation cannot stand in for it: the observation preserves the original PID-to-start-time pairing, so a recycled PID would still match it and take a signal meant for the process that exited. Reading the fence per target also keeps a failed read costing one target instead of the rest of a teardown round, which is what the [synchronous exit-cleanup contract](../bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md) requires.
`signalMembers` and `waitForMembers` return before capturing anything when a round has no members, so a command that spawned no descendants pays no table read for its teardown sweeps.
Platform differences live in how a snapshot is built, not in what it promises:
- **macOS** builds it from one `ps` table. That table exposes neither a session id nor a state column, so `session` is empty and `alive` reports presence with a matching start identity.
- **Linux** walks `/proc` once, carrying each entry's parent, start identity, session, and state. `alive` treats the `Z`, `X`, and `x` states as quiescent, as a per-pid `stat` read did.
- **Windows** enumerates Toolhelp32 lazily, on the first `tree` question. It has no POSIX sessions, and answers `alive` from the live process handle, because wait state is not a table column there — so a snapshot asked only for liveness never enumerates. The terminal's Windows teardown polls liveness every 25 ms and would otherwise walk and discard the whole table each time.
`PosixProcessSnapshot` holds both POSIX shapes: a row's `session` and `state` are `undefined` where the platform's table omits them, which is what makes the macOS answers fall out of the shared implementation instead of a second class.
## Testing
`packages/subprocess/subprocess-local/tests/terminal.spec.ts` drives a real `MacProcessInspector` over an injected `exec` and asserts one foreground inspection performs exactly one `-axo` table read at 0, 2, and 10 descendants. That count, not wall time, is the durable invariant: it holds on any host and fails the moment a caller re-reads the table per member. The same file pins that a signalling round with no members captures nothing, and that a capture failure during synchronous host exit still lets the PTY root be killed.
`process-inspector.spec.ts` pins the fence directly: an identity observed alive and then absent from the table takes no signal. `windows-inspector.spec.ts` pins that a snapshot answering only liveness performs no Toolhelp32 enumeration.
## Alternatives considered
**A batched `aliveMembers(members)` call, leaving the other methods alone.** This collapses the per-member reads and is a much smaller edit, but the tree read stays separate, so a macOS poll still forks `ps` twice plus the `tpgid` read — about 32 ms at 10 descendants, still 64% of the 50 ms interval. The event loop remains mostly blocked, so the measured problem survives the fix.
**Caching the macOS table inside `MacProcessInspector` behind a short TTL.** This needs no interface change, but it makes staleness invisible: a caller cannot tell whether a liveness answer came from this instant or from the end of the previous poll, and a signal decided on a stale row is exactly what the PID-reuse fence exists to prevent. Hidden caching also conflicts with the repository's preference for explicit defaulting and explicit boundaries.
**Fencing signals with the round's shared observation.** This removes the last per-member read and was the shape first implemented here. Review rejected it: the fence exists to defeat PID reuse, and an observation defeats the fence instead, because it carries the original PID-to-start-time pairing forward. The window is narrow — the kills in one round are microseconds apart, against a PID space of 99999 on macOS and 4194304 on Linux — but the `README` states the guarantee without qualification, and buying microseconds of teardown time by weakening it is the wrong trade. Keeping both `snapshot().alive` and `isAlive` is therefore not two ways to ask one question: one asks what the table showed, the other asks what is true now, and only the second may decide a signal.
**Making `exec` asynchronous instead of reducing the read count.** An async `execFile` stops the poll from blocking the loop but still forks N+1 processes per poll; on a busy machine that trades a stall for sustained fork pressure. It remains a worthwhile follow-up on top of the reduced count, not a substitute for it.
## Consequences
A readiness poll's process-table cost is now constant in descendant count. On macOS one poll performs one full table read plus the small `tpgid` read, which is the 0-descendant cost in the table above for every descendant count.
Teardown keeps its previous per-signal cost: one narrow liveness read per target, which on macOS is one `ps` fork per member. That cost was never the measured problem — a terminal tears down once, while its readiness path polls up to 600 times — so the fix deliberately spends it to keep the fence reading current state.
A snapshot is a point-in-time view, and the type's documentation says so. `waitForMembers` re-captures per iteration because observing change is its purpose, and no signal is ever decided from a captured view.
Every `ProcessInspector` implementation and test fake carries the new shape, including the Windows inspector and the `dsh-terminal-bash` session fake. Test fakes that previously replaced `processTree`, `processSession`, or `isAlive` to stage a scan now replace the corresponding per-question read hook, which keeps their staging behavior and call-counting identical.
The synchronous `execFileSync` boundary and the fixed 50 ms poll interval are unchanged; both remain open follow-ups for the same readiness path.
@@ -0,0 +1,71 @@
# Agent Note: Process-table snapshots replace per-question inspector reads
Status: implemented
[English](2026-08-27-process-table-snapshots.md) | 中文
## Problem
一次终端就绪轮询要向平台问三个问题:shell 的子进程树、它的 POSIX 会话成员、以及每个被跟踪的子进程是否仍在运行。当每个问题各自去读一次进程表时,这次轮询的代价就随着当前命令派生出的子进程数量增长。
在 macOS 上每一次读取都是一次 `/bin/ps -axo` fork,并解析整张表——在实测主机上 795 个进程需要 14.33 ms。`LocalTerminalHandle.inspectForeground()` 读一次树,然后按每个被跟踪的子进程各问一次存活,所以 N 个子进程的一次轮询要读 N+1 次表。`dsh-terminal-bash` 每 50 ms 轮询一次、最长 30 s,而 `ProcessInspectorInternals.exec``execFileSync`,因此每次轮询在其整个时长内阻塞事件循环。
用生产环境的 `MacProcessInspector` 驱动真实进程树实测:
| 被跟踪的子进程数 | 一次轮询 | 占 50 ms 间隔的比例 |
|---|---|---|
| 0 | 18.0 ms | 36% |
| 1 | 33.8 ms | 68% |
| 2 | 49.1 ms | 98% |
| 5 | 87.1 ms | 174% |
| 10 | 178.4 ms | 357% |
任何派生两个及以上子进程的命令——一条管道、`make``pnpm``git`——都会把宿主事件循环打满,直到它退出。
拆卸路径的结构相同。`signalProcess` 自己去问存活来给每个信号加 PID 复用围栏,因此向 N 个成员发信号要读 N 次表。
## Decision
`ProcessInspector.snapshot()` 返回一个 `ProcessSnapshot`,即对进程表的一次观察,由它回答 `tree(rootPid)``session(sessionId)``alive(identity)`。它取代了那三个按问题划分的方法;检查器剩下的接口是 `foregroundPgid``isStdinWaiting``signalGroup``signalProcess`
每个调用方捕获一次快照,并从中回答本次流程的全部问题。`LocalTerminalHandle.descendants()` 取一次快照,从中读取树与会话,并用同一个 `alive` 过滤幸存者,因此一次就绪轮询无论有多少子进程都只读一次表。`waitForMembers` 每一轮轮询各捕获一次新快照,因为它的用途正是观察变化。
发信号不共用这份观察。`ProcessInspector.isAlive(identity)` 用各平台最窄的按标识来源回答当前状态——Linux 读一个 `/proc/<pid>/stat`、macOS 读一次 `ps` 表、Windows 查一次进程句柄——`signalProcess` 在投递信号前就地取这道围栏。观察无法代替它:观察把原始的「PID 与起始时间」配对保留了下来,因此被复用的 PID 仍会与之匹配,并领走本该发给已退出进程的信号。逐目标读取围栏还让一次失败的读取只损失一个目标,而不是整轮拆卸的其余部分,这正是[同步退出清理约定](../bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md)的要求。
`signalMembers``waitForMembers` 在一轮没有成员时直接返回、不做任何捕获,因此没有派生子进程的命令,其拆卸扫描不付表读取代价。
平台差异体现在快照如何构建,而不在它承诺什么:
- **macOS** 由一张 `ps` 表构建。该表既不暴露会话 id 也不暴露状态列,所以 `session` 为空,`alive` 报告的是「存在且起始标识匹配」。
- **Linux** 遍历一次 `/proc`,携带每个条目的父进程、起始标识、会话与状态。`alive``Z``X``x` 状态视为静止,与按 pid 读 `stat` 的判定一致。
- **Windows** 把 Toolhelp32 枚举惰性化到第一次 `tree` 提问。它没有 POSIX 会话,并且从活的进程句柄回答 `alive`,因为等待状态在那里不是表的一列——所以只问存活的快照永不枚举。终端在 Windows 上的拆卸每 25 ms 轮询一次存活,否则每次都会遍历整张表再丢弃。
`PosixProcessSnapshot` 同时承载两种 POSIX 形态:当平台的表省略某字段时,该行的 `session``state``undefined`,这使得 macOS 的答案从共享实现中自然得出,而不必新增一个类。
## Testing
`packages/subprocess/subprocess-local/tests/terminal.spec.ts` 通过注入的 `exec` 驱动真实的 `MacProcessInspector`,断言一次前台检查在 0、2、10 个子进程下都恰好执行一次 `-axo` 表读取。这个次数——而非墙钟时间——才是持久不变量:它在任何主机上都成立,并且在任何调用方按成员重复读表的那一刻失败。同一文件还钉住:没有成员的一轮信号不做任何捕获;同步主机退出期间捕获失败时,PTY root 仍会被杀掉。
`process-inspector.spec.ts` 直接钉住围栏:一个先被观察为存活、随后从表中消失的标识不会收到信号。`windows-inspector.spec.ts` 钉住只回答存活的快照不执行 Toolhelp32 枚举。
## Alternatives considered
**只加一个批量的 `aliveMembers(members)`,其余方法不动。** 这能合并按成员的读取,改动也小得多,但树的读取仍然独立,因此 macOS 上一次轮询仍要 fork 两次 `ps` 外加 `tpgid` 读取——10 个子进程时约 32 ms,仍占 50 ms 间隔的 64%。事件循环依旧大部分时间被阻塞,实测到的问题在修复之后依然存在。
**在 `MacProcessInspector` 内部用短 TTL 缓存 macOS 的表。** 这不需要改接口,但它让陈旧性不可见:调用方无法分辨一个存活答案来自此刻还是来自上一次轮询结束时,而基于陈旧行发出的信号正是 PID 复用围栏要防止的事情。隐式缓存也与仓库偏好显式默认与显式边界的立场冲突。
**用整轮共享的观察来给信号加围栏。** 这能去掉最后一处按成员的读取,也是本次最初实现的形态。评审否决了它:围栏的存在就是为了击败 PID 复用,而观察反过来击败了围栏——因为它把原始的「PID 与起始时间」配对一路带了下来。窗口确实很窄(一轮里各次 kill 相隔微秒级,而 macOS 的 PID 空间是 99999、Linux 是 4194304),但 `README` 是无条件地声明这条保证的,用削弱它来换取微秒级的拆卸时间是错误的取舍。因此同时保留 `snapshot().alive``isAlive` 并不是同一个问题的两种问法:前者问表当时显示了什么,后者问此刻什么为真,而只有后者可以决定一次信号。
**把 `exec` 改成异步,而不是减少读取次数。** 异步的 `execFile` 能让轮询不再阻塞事件循环,但每次轮询仍然 fork N+1 个进程;在繁忙的机器上这是把一次停顿换成了持续的 fork 压力。它在减少读取次数之上仍是值得做的后续项,而不是它的替代。
## Consequences
一次就绪轮询的进程表代价现在与子进程数量无关。在 macOS 上,一次轮询执行一次完整表读取加一次小的 `tpgid` 读取,也就是上表中 0 子进程那一行的代价,对任意子进程数量都成立。
拆卸保持原有的按次代价:每个目标一次窄的存活读取,在 macOS 上即每个成员一次 `ps` fork。这项代价从来不是实测到的问题——一个终端只拆卸一次,而它的就绪路径最多轮询 600 次——所以本次修复刻意付出它,以保证围栏读的是当前状态。
快照是一个时间点视图,该类型的文档也这样声明。`waitForMembers` 每轮重新捕获是因为观察变化正是它的用途;任何信号都不会从一份已捕获的视图上做决定。
每个 `ProcessInspector` 实现与测试替身都采用新形态,包括 Windows 检查器和 `dsh-terminal-bash` 的会话替身。此前通过替换 `processTree``processSession``isAlive` 来编排扫描的测试替身,现在替换对应的按问题读取钩子,其编排行为与调用计数保持不变。
同步的 `execFileSync` 边界与固定的 50 ms 轮询间隔未做改动;两者都仍是同一条就绪路径上待办的后续项。
@@ -16,6 +16,42 @@ interface FileStatus {
isCharacterDevice(): boolean
}
/**
* One observation of the platform process table, shared by every question a
* single readiness poll or teardown pass asks.
*
* The table is read at most once, on the first question that needs it — a
* `/bin/ps` fork on macOS, a `/proc` walk on Linux, a Toolhelp32 enumeration on
* Windows. Later questions never re-read it, which is what keeps a poll's cost
* independent of how many descendants the running command spawned. Windows
* liveness needs no table at all: wait state is a per-handle question there, so
* a snapshot asked only for liveness never enumerates.
*
* A snapshot answers what the process table showed, which is what batch
* filtering wants and what signalling must not use: {@link ProcessInspector.isAlive}
* is the fence a signal takes, because it reads current state instead.
*/
export interface ProcessSnapshot {
/**
* Return the root and its transitive descendants as observed, children first.
* @param rootPid - tree root to descend from.
* @returns Observed root and descendants, children before parents.
*/
tree(rootPid: number): ProcessIdentity[]
/**
* Return observed members of one POSIX process session.
* @param sessionId - POSIX session identifier.
* @returns Observed session members, empty where the platform's table omits session ids.
*/
session(sessionId: number): ProcessIdentity[]
/**
* Return whether the exact identity was a non-quiescent process.
* @param identity - PID plus start identity to match.
* @returns Whether that exact identity — not merely that PID — was running.
*/
alive(identity: ProcessIdentity): boolean
}
/** Injectable OS process operations used by one local PTY session. */
export interface ProcessInspector {
foregroundPgid(shellPid: number): number | undefined
@@ -27,13 +63,35 @@ export interface ProcessInspector {
* @returns Whether a group member is blocked reading the shell's terminal input.
*/
isStdinWaiting(pgid: number, shellPid: number): boolean
/** Return the root and its current transitive descendants, children first. */
processTree(rootPid: number): ProcessIdentity[]
/** Return current members of one POSIX process session when the platform exposes them. */
processSession(sessionId: number): ProcessIdentity[]
/** Return whether the exact identity remains a non-quiescent process. */
/**
* Read the process table once and answer tree, session, and liveness from it.
* @returns A process-table observation whose reads are shared.
*/
snapshot(): ProcessSnapshot
/**
* Return whether the exact identity is a non-quiescent process right now.
*
* Reads the narrowest per-identity source the platform offers rather than a
* whole table, so a signalling round can re-check every target without
* paying for a scan. Callers filtering many members at once want
* {@link ProcessSnapshot.alive} instead.
*
* @param identity - PID plus start identity to match.
* @returns Whether that exact identity — not merely that PID — is running.
*/
isAlive(identity: ProcessIdentity): boolean
signalGroup(pgid: number, signal: SubprocessTerminalSignal): void
/**
* Signal one exact process identity, fenced against PID reuse.
*
* The fence reads current state immediately before the signal. An observation
* taken earlier in the same round cannot stand in for it: the observation
* preserves the original PID-to-start-time pairing, so a recycled PID would
* still match and take a signal meant for the process that exited.
*
* @param identity - PID plus start identity to signal.
* @param signal - termination signal to deliver.
*/
signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void
}
@@ -292,8 +350,7 @@ abstract class PosixProcessInspector implements ProcessInspector {
abstract foregroundPgid(shellPid: number): number | undefined
abstract isStdinWaiting(pgid: number, shellPid: number): boolean
abstract processTree(rootPid: number): ProcessIdentity[]
abstract processSession(sessionId: number): ProcessIdentity[]
abstract snapshot(): ProcessSnapshot
abstract isAlive(identity: ProcessIdentity): boolean
signalGroup(pgid: number, signal: SubprocessTerminalSignal): void {
@@ -309,6 +366,42 @@ interface ProcessTreeEntry extends ProcessIdentity {
parentPid: number
}
/** One process-table row, carrying the fields a platform's table exposes. */
interface ProcessRow extends ProcessTreeEntry {
/** POSIX session identifier, or undefined where the table omits it. */
session: number | undefined
/** Single-letter process state, or undefined where the table omits it. */
state: string | undefined
}
// Zombie and dead states answer "present in the table" but never "still
// running"; a table without a state column can only report presence.
function quiescent(state: string | undefined): boolean {
return state !== undefined && /^[ZXx]$/.test(state)
}
class PosixProcessSnapshot implements ProcessSnapshot {
private readonly byPid: Map<number, ProcessRow>
constructor(private readonly rows: ProcessRow[]) {
this.byPid = new Map(rows.map(row => [row.pid, row]))
}
tree(rootPid: number): ProcessIdentity[] {
return processTree(this.rows, rootPid)
}
session(sessionId: number): ProcessIdentity[] {
return this.rows.flatMap(row =>
row.session === sessionId ? [{ pid: row.pid, started: row.started }] : [])
}
alive(identity: ProcessIdentity): boolean {
const row = this.byPid.get(identity.pid)
return row?.started === identity.started && !quiescent(row.state)
}
}
function processTree(entries: ProcessTreeEntry[], rootPid: number): ProcessIdentity[] {
const byPid = new Map(entries.map(entry => [entry.pid, entry]))
const root = byPid.get(rootPid)
@@ -364,35 +457,39 @@ class LinuxProcessInspector extends PosixProcessInspector {
return false
}
processTree(rootPid: number): ProcessIdentity[] {
const entries = numericEntries(this.internals, '/proc').flatMap((pid) => {
const stat = readLinuxStat(this.internals, pid)
return stat === undefined ? [] : [{ pid, parentPid: stat.parentPid, started: stat.started }]
})
return processTree(entries, rootPid)
}
processSession(sessionId: number): ProcessIdentity[] {
return numericEntries(this.internals, '/proc').flatMap((pid) => {
const stat = readLinuxStat(this.internals, pid)
return stat?.session === sessionId ? [{ pid, started: stat.started }] : []
})
}
isAlive(identity: ProcessIdentity): boolean {
const stat = readLinuxStat(this.internals, identity.pid)
return stat?.started === identity.started && !/^[ZXx]$/.test(stat.state)
return stat?.started === identity.started && !quiescent(stat.state)
}
snapshot(): ProcessSnapshot {
return new PosixProcessSnapshot(numericEntries(this.internals, '/proc').flatMap((pid) => {
const stat = readLinuxStat(this.internals, pid)
return stat === undefined ? [] : [{
pid,
parentPid: stat.parentPid,
started: stat.started,
session: stat.session,
state: stat.state,
}]
}))
}
}
interface PsEntry extends ProcessTreeEntry {}
function macProcessTable(internals: ProcessInspectorInternals): PsEntry[] {
// `ps` exposes neither the session id nor a state column in this format, so a
// macOS row can answer presence and parentage but never session membership.
function macProcessTable(internals: ProcessInspectorInternals): ProcessRow[] {
return internals.exec('/bin/ps', ['-axo', 'pid=,ppid=,lstart=']).split('\n').flatMap((line) => {
const match = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/.exec(line)
if (match?.[1] === undefined || match[2] === undefined || match[3] === undefined) return []
return [{ pid: Number(match[1]), parentPid: Number(match[2]), started: match[3] }]
return [{
pid: Number(match[1]),
parentPid: Number(match[2]),
started: match[3],
session: undefined,
state: undefined,
}]
})
}
@@ -410,16 +507,13 @@ class MacProcessInspector extends PosixProcessInspector {
return false
}
processTree(rootPid: number): ProcessIdentity[] {
return processTree(macProcessTable(this.internals), rootPid)
}
processSession(_sessionId: number): ProcessIdentity[] {
return []
}
isAlive(identity: ProcessIdentity): boolean {
return macProcessTable(this.internals).some(entry => entry.pid === identity.pid && entry.started === identity.started)
return macProcessTable(this.internals)
.some(entry => entry.pid === identity.pid && entry.started === identity.started)
}
snapshot(): ProcessSnapshot {
return new PosixProcessSnapshot(macProcessTable(this.internals))
}
}
@@ -10,7 +10,7 @@ import type {
SubprocessTerminalHandle,
SubprocessTerminalSignal,
} from '@deepseek-ai/dsh-subprocess'
import type { ProcessIdentity, ProcessInspector } from './process-inspector.ts'
import type { ProcessIdentity, ProcessInspector, ProcessSnapshot } from './process-inspector.ts'
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
@@ -59,7 +59,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
private readonly platform: NodeJS.Platform = process.platform,
) {
this.pid = terminal.pid
this.rootIdentity = inspector.processTree(this.pid).find(member => member.pid === this.pid)
this.rootIdentity = inspector.snapshot().tree(this.pid).find(member => member.pid === this.pid)
this.done = this.outcome.promise
this.dataDisposable = terminal.onData((data) => { this.output.write(Buffer.from(data, 'utf8')) })
this.exitDisposable = terminal.onExit(({ exitCode, signal: exitSignal }) => {
@@ -83,7 +83,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
// Local inspection is synchronous; the seam returns a promise for remote transports.
// oxlint-disable-next-line typescript/require-await -- Preserve promise rejection semantics at the async provider contract.
async inspectForeground(): Promise<SubprocessTerminalForeground | undefined> {
this.descendants()
this.descendants(this.inspector.snapshot())
const processGroupId = this.inspector.foregroundPgid(this.pid)
if (processGroupId === undefined) return undefined
return {
@@ -152,34 +152,35 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
}
}
private survivors(members: ProcessIdentity[]): ProcessIdentity[] {
return members.filter(member => this.inspector.isAlive(member))
private survivors(members: ProcessIdentity[], observed: ProcessSnapshot): ProcessIdentity[] {
return members.filter(member => observed.alive(member))
}
private descendants(): ProcessIdentity[] {
private descendants(observed: ProcessSnapshot): ProcessIdentity[] {
// Adopt newly scanned members only while the numeric root pid provably
// still carries the spawned shell's start identity: after the shell dies,
// a recycled pid's tree and session must not donate an unrelated
// process's children to this session's signalling. Already-adopted
// members keep their own start identities, which every signal rechecks.
const tree = this.inspector.processTree(this.pid)
const tree = observed.tree(this.pid)
const root = tree.find(member => member.pid === this.pid)
const rootVerified = this.rootIdentity !== undefined
&& root !== undefined
&& root.started === this.rootIdentity.started
this.trackedDescendants = this.survivors(this.unionMembers(
this.trackedDescendants,
...rootVerified ? [tree, this.inspector.processSession(this.pid)] : [],
).filter(member => member.pid !== this.pid))
...rootVerified ? [tree, observed.session(this.pid)] : [],
).filter(member => member.pid !== this.pid), observed)
return this.trackedDescendants
}
private async waitForMembers(members: ProcessIdentity[]): Promise<ProcessIdentity[]> {
if (members.length === 0) return []
const until = Date.now() + this.graceMs
let survivors = this.survivors(members)
let survivors = this.survivors(members, this.inspector.snapshot())
while (survivors.length > 0 && Date.now() < until) {
await delay(Math.min(25, Math.max(1, until - Date.now())))
survivors = this.survivors(members)
survivors = this.survivors(members, this.inspector.snapshot())
}
return survivors
}
@@ -187,6 +188,8 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
private signalMembers(members: ProcessIdentity[], signal: 'SIGTERM' | 'SIGKILL'): void {
for (const member of members) {
try {
// Each signal reads its own identity fence, inside this try: a failed
// read must cost one target, never the rest of a teardown round.
this.inspector.signalProcess(member, signal)
} catch (_alreadyExitedDuringSignal) {
// The exact process identity is rechecked; a same-tick exit is success.
@@ -197,7 +200,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
private forceStopDescendants(): void {
let members = this.trackedDescendants
try {
members = this.descendants()
members = this.descendants(this.inspector.snapshot())
} catch (_processTableUnavailableDuringHostExit) {
// Preserve already-captured identities when a final process-table scan fails.
}
@@ -219,13 +222,14 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
}
private async stopDescendants(): Promise<ProcessIdentity[]> {
const captured = this.descendants()
const captured = this.descendants(this.inspector.snapshot())
this.signalMembers(captured, 'SIGTERM')
const capturedSurvivors = await this.waitForMembers(captured)
const members = this.unionMembers(capturedSurvivors, this.descendants())
const members = this.unionMembers(capturedSurvivors, this.descendants(this.inspector.snapshot()))
this.signalMembers(members, 'SIGKILL')
const survivors = await this.waitForMembers(members)
return this.survivors(this.unionMembers(survivors, this.descendants()))
const observed = this.inspector.snapshot()
return this.survivors(this.unionMembers(survivors, this.descendants(observed)), observed)
}
private async stopShell(): Promise<void> {
@@ -12,7 +12,7 @@
import { spawnSync } from 'node:child_process'
import koffi from 'koffi'
import type { SubprocessTerminalSignal } from '@deepseek-ai/dsh-subprocess'
import type { ProcessIdentity, ProcessInspector } from './process-inspector.ts'
import type { ProcessIdentity, ProcessInspector, ProcessSnapshot } from './process-inspector.ts'
/** One Toolhelp32 process-table row. */
export interface ProcessEntry {
@@ -97,19 +97,28 @@ export class WindowsProcessInspector implements ProcessInspector {
return false
}
processTree(rootPid: number): ProcessIdentity[] {
return windowsProcessTree(this.internals.snapshot(), rootPid, pid => this.internals.processState(pid)?.started)
}
processSession(_sessionId: number): ProcessIdentity[] {
return []
}
isAlive(identity: ProcessIdentity): boolean {
const state = this.internals.processState(identity.pid)
return state?.active === true && state.started === identity.started
}
snapshot(): ProcessSnapshot {
// Enumerated on the first question that reads the table. Liveness never
// does — wait state is a per-handle question here — so the Windows
// teardown poll, which asks only for liveness, pays no Toolhelp32 walk.
let entries: ProcessEntry[] | undefined
return {
tree: rootPid => windowsProcessTree(
entries ??= this.internals.snapshot(),
rootPid,
pid => this.internals.processState(pid)?.started,
),
// Windows has no POSIX sessions; the shell pid stands in as a pseudo group.
session: () => [],
alive: identity => this.isAlive(identity),
}
}
signalGroup(pgid: number, signal: SubprocessTerminalSignal): void {
this.internals.taskkill(pgid, signal === 'SIGKILL')
}
@@ -302,8 +302,7 @@ describe('LocalSubprocessRuntime', () => {
const inspector = {
foregroundPgid: () => undefined,
isStdinWaiting: () => false,
processTree: () => [],
processSession: () => [],
snapshot: () => ({ tree: () => [], session: () => [], alive: () => false }),
isAlive: () => false,
signalGroup: () => {},
signalProcess: () => {},
@@ -369,8 +368,11 @@ describe('LocalSubprocessRuntime', () => {
;(ctx.subprocess as InstanceType<typeof IsolatedLocalSubprocessRuntime>).terminalInspector = {
foregroundPgid: () => 123,
isStdinWaiting: () => false,
processTree: () => [{ pid: 123, started: 'shell' }, { pid: 124, started: 'child' }],
processSession: () => [],
snapshot: () => ({
tree: () => [{ pid: 123, started: 'shell' }, { pid: 124, started: 'child' }],
session: () => [],
alive: identity => alive.has(identity.pid),
}),
isAlive: identity => alive.has(identity.pid),
signalGroup: () => {},
signalProcess: () => {},
@@ -42,7 +42,7 @@ async function readTree(path: string): Promise<TreeState> {
async function captureIdentities(inspector: ProcessInspector, state: TreeState): Promise<ProcessIdentity[]> {
return vi.waitFor(() => {
const expected = new Set([state.root, state.descendant])
const identities = inspector.processTree(state.root).filter(identity => expected.has(identity.pid))
const identities = inspector.snapshot().tree(state.root).filter(identity => expected.has(identity.pid))
if (identities.length !== expected.size) throw new Error('managed tree is not fully observable yet')
return identities
}, { interval: 10, timeout: scenarioTimeoutMs })
@@ -121,26 +121,30 @@ describe('Linux process inspector', () => {
expect(inspector.foregroundPgid(10)).toBe(40)
expect(inspector.foregroundPgid(11)).toBeUndefined()
expect(inspector.foregroundPgid(99)).toBeUndefined()
expect(inspector.processTree(10)).toEqual([
const observed = inspector.snapshot()
expect(observed.tree(10)).toEqual([
{ pid: 13, started: '503' },
{ pid: 12, started: '502' },
{ pid: 10, started: '500' },
])
expect(inspector.processTree(99)).toEqual([])
expect(inspector.processSession(30)).toEqual([
expect(observed.tree(99)).toEqual([])
expect(observed.session(30)).toEqual([
{ pid: 10, started: '500' },
{ pid: 11, started: '501' },
{ pid: 12, started: '502' },
{ pid: 13, started: '503' },
])
expect(inspector.processSession(99)).toEqual([])
expect(inspector.isAlive({ pid: 10, started: '500' })).toBe(true)
expect(inspector.isAlive({ pid: 10, started: 'old' })).toBe(false)
expect(observed.session(99)).toEqual([])
expect(observed.alive({ pid: 10, started: '500' })).toBe(true)
expect(observed.alive({ pid: 10, started: 'old' })).toBe(false)
inspector.signalGroup(40, 'SIGINT')
inspector.signalProcess({ pid: 10, started: '500' }, 'SIGTERM')
inspector.signalProcess({ pid: 10, started: 'old' }, 'SIGKILL')
expect(fake.kills).toEqual([[-40, 'SIGINT'], [10, 'SIGTERM']])
fake.files.set('/proc/10/stat', stat(10, 20, 30, 40, '500', 1, 'Z'))
// A zombie is present in the table but never signallable; both the batch
// view and the signal fence report it quiescent once the state changes.
expect(inspector.snapshot().alive({ pid: 10, started: '500' })).toBe(false)
expect(inspector.isAlive({ pid: 10, started: '500' })).toBe(false)
inspector.signalProcess({ pid: 10, started: '500' }, 'SIGKILL')
expect(fake.kills).toEqual([[-40, 'SIGINT'], [10, 'SIGTERM']])
@@ -285,26 +289,41 @@ describe('macOS process inspector', () => {
const inspector = createProcessInspector('darwin', 'arm64', fake.internals)
expect(inspector.foregroundPgid(10)).toBe(55)
expect(inspector.isStdinWaiting(55, 10)).toBe(false)
expect(inspector.processTree(10)).toEqual([
const observed = inspector.snapshot()
expect(observed.tree(10)).toEqual([
{ pid: 12, started: 'Mon Jul 21 10:00:02 2026' },
{ pid: 11, started: 'Mon Jul 21 10:00:01 2026' },
{ pid: 10, started: 'Mon Jul 21 10:00:00 2026' },
])
expect(inspector.processTree(99)).toEqual([])
expect(inspector.processSession(10)).toEqual([])
expect(inspector.isAlive({ pid: 11, started: 'Mon Jul 21 10:00:01 2026' })).toBe(true)
expect(observed.tree(99)).toEqual([])
expect(observed.session(10)).toEqual([])
expect(observed.alive({ pid: 11, started: 'Mon Jul 21 10:00:01 2026' })).toBe(true)
inspector.signalGroup(55, 'SIGTSTP')
inspector.signalProcess({ pid: 11, started: 'Mon Jul 21 10:00:01 2026' }, 'SIGKILL')
inspector.signalProcess({ pid: 12, started: 'missing' }, 'SIGTERM')
expect(fake.kills).toEqual([[-55, 'SIGTSTP'], [11, 'SIGKILL']])
fake.setPs(' 10 11 Mon Jul 21 10:00:00 2026\n 11 10 Mon Jul 21 10:00:01 2026\n')
expect(inspector.processTree(10)).toEqual([
expect(inspector.snapshot().tree(10)).toEqual([
{ pid: 11, started: 'Mon Jul 21 10:00:01 2026' },
{ pid: 10, started: 'Mon Jul 21 10:00:00 2026' },
])
})
it('re-reads the process table before signalling instead of trusting an earlier observation', () => {
const fake = fakeInternals()
fake.setPs(' 11 10 Mon Jul 21 10:00:01 2026\n')
const inspector = createProcessInspector('darwin', 'arm64', fake.internals)
inspector.snapshot()
// The member exits after that observation; a recycled pid would otherwise
// inherit the observed identity and take the signal meant for the original.
fake.setPs('')
inspector.signalProcess({ pid: 11, started: 'Mon Jul 21 10:00:01 2026' }, 'SIGKILL')
expect(fake.kills).toEqual([])
})
it('returns undefined for missing or invalid foreground groups and dispatches platform inspectors', () => {
const fake = fakeInternals()
fake.setTpgid('-1')
@@ -1,9 +1,12 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { IDisposable, IPty } from 'node-pty'
import { LocalTerminalHandle } from '@deepseek-ai/dsh-subprocess-local/src/terminal.ts'
import { createProcessInspector } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts'
import type {
ProcessIdentity,
ProcessInspector,
ProcessInspectorInternals,
ProcessSnapshot,
} from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts'
import type { SubprocessTerminalSignal } from '@deepseek-ai/dsh-subprocess'
@@ -69,16 +72,31 @@ class FakeInspector implements ProcessInspector {
this.stdinChecks.push([pgid, shellPid])
return this.waiting
}
processTree() { return this.root === undefined ? this.members : [this.root, ...this.members] }
processSession() { return this.sessionMembers }
isAlive(identity: ProcessIdentity) { return this.alive.has(identity.pid) }
/** Per-question table reads; tests replace one to stage a scan without rebuilding the fake. */
readTree: () => ProcessIdentity[] = () => this.root === undefined ? this.members : [this.root, ...this.members]
readSession: () => ProcessIdentity[] = () => this.sessionMembers
readAlive: (identity: ProcessIdentity) => boolean = identity => this.alive.has(identity.pid)
/** Liveness as of right now; tests diverge it from readAlive to stage an exit between scan and signal. */
readCurrentAlive: (identity: ProcessIdentity) => boolean = identity => this.readAlive(identity)
/** Counts process-table captures so read-amplification cases can pin them. */
captures = 0
snapshot(): ProcessSnapshot {
this.captures += 1
return {
tree: () => this.readTree(),
session: () => this.readSession(),
alive: identity => this.readAlive(identity),
}
}
isAlive(identity: ProcessIdentity) { return this.readCurrentAlive(identity) }
signalGroup(pgid: number, signal: SubprocessTerminalSignal) {
if (this.throwGroup) throw new Error('group failed')
this.groups.push([pgid, signal])
}
signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL') {
// Mirrors the real inspectors' alive-gated signalling.
if (!this.alive.has(identity.pid)) return
if (this.throwProcess) throw new Error('process raced')
if (!this.isAlive(identity)) return
this.processes.push([identity.pid, signal])
@@ -135,7 +153,7 @@ describe('LocalTerminalHandle', () => {
inspector.alive.add(captured.pid)
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
await handle.inspectForeground()
inspector.processTree = () => { throw new Error('process table unavailable') }
inspector.readTree = () => { throw new Error('process table unavailable') }
inspector.throwProcess = true
expect(() => { handle.terminateForHostExit() }).not.toThrow()
@@ -166,7 +184,7 @@ describe('LocalTerminalHandle', () => {
inspector.alive.add(pty.pid)
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
inspector.root = { pid: pty.pid, started: 'recycled' }
inspector.isAlive = identity => identity.started === 'recycled'
inspector.readAlive = identity => identity.started === 'recycled'
handle.terminateForHostExit()
@@ -258,7 +276,7 @@ describe('LocalTerminalHandle', () => {
const pty = new FakePty()
const inspector = new FakeInspector()
const disowned = { pid: 124, started: 'disowned' }
inspector.processSession = () => inspector.alive.has(disowned.pid) ? [disowned] : []
inspector.readSession = () => inspector.alive.has(disowned.pid) ? [disowned] : []
inspector.alive.add(124)
const handle = makeHandle(pty, inspector, 20)
@@ -318,7 +336,7 @@ describe('LocalTerminalHandle', () => {
const inspector = new FakeInspector()
const root = { pid: 123, started: 'shell' }
let reads = 0
inspector.processTree = () => {
inspector.readTree = () => {
reads += 1
if (reads === 1) return [root]
if (reads === 2) {
@@ -385,7 +403,7 @@ describe('LocalTerminalHandle', () => {
const root = { pid: 123, started: 'shell' }
let reads = 0
inspector.alive.add(captured.pid)
inspector.processTree = () => { reads += 1; return reads === 1 ? [root] : reads === 2 ? [root, captured] : [] }
inspector.readTree = () => { reads += 1; return reads === 1 ? [root] : reads === 2 ? [root, captured] : [] }
inspector.signalProcess = (identity, signal) => {
inspector.processes.push([identity.pid, signal])
if (signal === 'SIGKILL') inspector.alive.delete(identity.pid)
@@ -514,3 +532,87 @@ describe('LocalTerminalHandle on Windows', () => {
expect(inspector.processes).toEqual([])
})
})
describe('signalling freshness and containment', () => {
it('keeps synchronous host exit going when the process table cannot be captured', () => {
const pty = new FakePty()
const inspector = new FakeInspector()
inspector.alive.add(pty.pid)
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
inspector.snapshot = () => { throw new Error('process table unavailable') }
expect(() => { handle.terminateForHostExit() }).not.toThrow()
// forceStopShell still runs: a failed scan must not cost the PTY root.
expect(inspector.processes).toEqual([[pty.pid, 'SIGKILL']])
})
it('captures no process table for a signalling round with no members', () => {
const pty = new FakePty()
const inspector = new FakeInspector()
inspector.alive.add(pty.pid)
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
// Only the shell exists, so every descendant scan yields an empty round.
inspector.readTree = () => [{ pid: pty.pid, started: 'shell' }]
inspector.captures = 0
handle.terminateForHostExit()
// Two descendant scans and nothing else: no capture for either empty
// signalling round, and none for the identity-fenced shell kill.
expect(inspector.captures).toBe(2)
})
})
describe('process-table read amplification', () => {
// The macOS inspector answers every question by forking `/bin/ps`, so a
// readiness poll that asks per descendant scales its blocking cost with the
// command's process tree. These pin the read count, not the wall time.
function darwinInternals(table: string): { internals: ProcessInspectorInternals; tableReads: string[] } {
const tableReads: string[] = []
const unreachable = (): never => { throw new Error('darwin inspection uses exec and kill only') }
return {
tableReads,
internals: {
readFile: unreachable,
readDir: unreachable,
readLink: unreachable,
stat: unreachable,
open: unreachable,
read: unreachable,
close: unreachable,
exec(_file, args) {
if (args.includes('tpgid=')) return '456\n'
tableReads.push(args.join(' '))
return table
},
kill() {},
},
}
}
/** A shell at pid 123 with `count` descendants chained beneath it. */
function shellTable(count: number): string {
const rows = [' 123 1 Mon Jul 21 10:00:00 2026']
for (let index = 0; index < count; index += 1) {
rows.push(` ${String(124 + index)} ${String(123 + index)} Mon Jul 21 10:00:${String(index + 1).padStart(2, '0')} 2026`)
}
return `${rows.join('\n')}\n`
}
async function tableReadsForOnePoll(descendants: number): Promise<number> {
const { internals, tableReads } = darwinInternals(shellTable(descendants))
const inspector = createProcessInspector('darwin', 'arm64', internals)
const handle = new LocalTerminalHandle(new FakePty().asPty(), inspector, 10, 'darwin')
tableReads.length = 0
const foreground = await handle.inspectForeground()
expect(foreground).toEqual({ processGroupId: 456, inputWaiting: false })
return tableReads.length
}
it('reads the macOS process table once per foreground inspection regardless of descendant count', async () => {
expect(await tableReadsForOnePoll(0)).toBe(1)
expect(await tableReadsForOnePoll(2)).toBe(1)
expect(await tableReadsForOnePoll(10)).toBe(1)
})
})
@@ -16,10 +16,12 @@ function fakeInternals() {
const entries: ProcessEntry[] = []
const states = new Map<number, WindowsProcessState>()
const kills: Array<[number, boolean]> = []
const counts = { enumerations: 0, stateReads: 0 }
return {
counts,
internals: {
snapshot: () => [...entries],
processState: pid => states.get(pid),
snapshot: () => { counts.enumerations += 1; return [...entries] },
processState: (pid) => { counts.stateReads += 1; return states.get(pid) },
taskkill: (pid: number, force: boolean) => { kills.push([pid, force]) },
} satisfies WindowsProcessInspectorInternals,
add(entry: ProcessEntry, started?: string, active = true): void {
@@ -30,6 +32,29 @@ function fakeInternals() {
}
}
describe('WindowsProcessInspector table enumeration', () => {
it('enumerates the process table only for questions that need it', () => {
const fake = fakeInternals()
fake.add({ pid: 10, parentPid: 0 }, 't10')
fake.add({ pid: 11, parentPid: 10 }, 't11')
const inspector = new WindowsProcessInspector(fake.internals)
// Liveness is a per-handle question on Windows, so a snapshot asked only
// for liveness must not pay a Toolhelp32 walk. The terminal's Windows
// teardown polls exactly this way, every 25 ms.
const observed = inspector.snapshot()
expect(observed.alive({ pid: 11, started: 't11' })).toBe(true)
expect(fake.counts.enumerations).toBe(0)
expect(observed.tree(10)).toHaveLength(2)
expect(fake.counts.enumerations).toBe(1)
// A second tree question reuses the same observation.
observed.tree(10)
expect(fake.counts.enumerations).toBe(1)
})
})
describe('windowsProcessTree', () => {
it('walks a table children-first with readable identities only', () => {
const started = (pid: number): string | undefined => pid === 12 ? undefined : `t${pid}`
@@ -66,7 +91,7 @@ describe('WindowsProcessInspector (injected internals)', () => {
const inspector = new WindowsProcessInspector(fake.internals)
expect(inspector.foregroundPgid(77)).toBe(77)
expect(inspector.isStdinWaiting(77, 10)).toBe(false)
expect(inspector.processSession(77)).toEqual([])
expect(inspector.snapshot().session(77)).toEqual([])
})
it('delegates tree walks and identity checks to the internals', () => {
@@ -74,7 +99,7 @@ describe('WindowsProcessInspector (injected internals)', () => {
fake.add({ pid: 10, parentPid: 0 }, 't10')
fake.add({ pid: 11, parentPid: 10 }, 't11')
const inspector = new WindowsProcessInspector(fake.internals)
expect(inspector.processTree(10)).toEqual([
expect(inspector.snapshot().tree(10)).toEqual([
{ pid: 11, started: 't11' },
{ pid: 10, started: 't10' },
])
@@ -130,10 +155,10 @@ const win32 = process.platform === 'win32' ? describe : describe.skip
win32('WindowsProcessInspector over the real koffi bindings', () => {
it('walks the live process table from the test runner itself', () => {
const inspector = createWindowsProcessInspector()
const tree = inspector.processTree(process.pid)
const tree = inspector.snapshot().tree(process.pid)
const self = tree.find(member => member.pid === process.pid)
expect(self).toBeDefined()
expect(inspector.isAlive(self!)).toBe(true)
expect(inspector.snapshot().alive(self!)).toBe(true)
expect(inspector.foregroundPgid(process.pid)).toBe(process.pid)
})
@@ -27,8 +27,13 @@ class FakeInspector implements ProcessInspector {
foregroundPgid() { return this.pgid }
isStdinWaiting() { return this.waiting }
processTree() { return this.members }
processSession() { return [] }
snapshot() {
return {
tree: () => this.members,
session: () => [],
alive: (identity: ProcessIdentity) => this.alive.has(identity.pid),
}
}
isAlive(identity: ProcessIdentity) { return this.alive.has(identity.pid) }
signalGroup(pgid: number, signal: TerminalSignal) {
if (this.throwGroup) throw new Error('group failed')