fix(subprocess): read the process table once per terminal poll

MacProcessInspector answered the descendant tree and every member's
liveness with its own `/bin/ps` fork, so one readiness poll cost N+1
full table reads for N tracked descendants. With execFileSync on that
path and a 50 ms poll interval, any command spawning two or more
children saturated the host event loop until it exited.

ProcessInspector.snapshot() now returns one ProcessSnapshot that
answers tree, session, and alive from a single observation, and
signalProcess takes the caller's observation so its PID-reuse fence
does not re-read the table per member.
This commit is contained in:
Yichen Jiang
2026-08-27 13:06:12 +08:00
parent a24c71127f
commit 32ddfcd89c
12 changed files with 417 additions and 132 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: 2f031cc2952ffe1c007e04acf4dabbeac0630630
2026-08-27-process-table-snapshots.zh.md: fbad15c306d250aeb64247a301ed20777f39c4a3
@@ -0,0 +1,67 @@
# 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.
`signalProcess(identity, signal, observed)` takes the caller's observation rather than reading the table itself. The PID-reuse fence stays, and `signalMembers` now captures once for a whole signalling round instead of once per member. Passing the observation explicitly is what keeps Linux teardown from regressing: `alive` there is answered from a `/proc` walk the snapshot already paid for, not from a fresh walk per member.
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** captures the Toolhelp32 enumeration for `tree`, has no POSIX sessions, and answers `alive` from the live process handle, because wait state is not a table column there.
`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.
## 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.
**Keeping `isAlive` on the inspector next to `snapshot()`.** This avoids touching the signalling call sites, at the cost of two ways to ask one question, where only one of them is cheap in a loop. The asymmetry would have to be re-explained at every call site.
**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.
Liveness for a single identity on Linux costs a full `/proc` walk rather than one `stat` read. Every caller that asks about several identities amortizes that walk across them, which is why `signalProcess` takes an observation rather than capturing its own; a future caller that genuinely needs one isolated liveness answer pays more than it did.
A snapshot is a point-in-time view, and the type's documentation says so. Holding one across an `await` and then signalling from it would widen the PID-reuse window that the fence narrows; `waitForMembers` re-captures per iteration for exactly this reason.
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,67 @@
# 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` 每一轮轮询各捕获一次新快照,因为它的用途正是观察变化。
`signalProcess(identity, signal, observed)` 接收调用方的观察,而不是自己去读表。PID 复用围栏保留,而 `signalMembers` 现在为整轮信号只捕获一次,而不是每个成员各一次。把观察显式传入正是 Linux 拆卸不退化的原因:那里的 `alive` 由快照已经付过代价的一次 `/proc` 遍历回答,而不是每个成员各遍历一次。
平台差异体现在快照如何构建,而不在它承诺什么:
- **macOS** 由一张 `ps` 表构建。该表既不暴露会话 id 也不暴露状态列,所以 `session` 为空,`alive` 报告的是「存在且起始标识匹配」。
- **Linux** 遍历一次 `/proc`,携带每个条目的父进程、起始标识、会话与状态。`alive``Z``X``x` 状态视为静止,与按 pid 读 `stat` 的判定一致。
- **Windows** 捕获 Toolhelp32 枚举供 `tree` 使用,没有 POSIX 会话,并且从活的进程句柄回答 `alive`,因为等待状态在那里不是表的一列。
`PosixProcessSnapshot` 同时承载两种 POSIX 形态:当平台的表省略某字段时,该行的 `session``state``undefined`,这使得 macOS 的答案从共享实现中自然得出,而不必新增一个类。
## Testing
`packages/subprocess/subprocess-local/tests/terminal.spec.ts` 通过注入的 `exec` 驱动真实的 `MacProcessInspector`,断言一次前台检查在 0、2、10 个子进程下都恰好执行一次 `-axo` 表读取。这个次数——而非墙钟时间——才是持久不变量:它在任何主机上都成立,并且在任何调用方按成员重复读表的那一刻失败。
## Alternatives considered
**只加一个批量的 `aliveMembers(members)`,其余方法不动。** 这能合并按成员的读取,改动也小得多,但树的读取仍然独立,因此 macOS 上一次轮询仍要 fork 两次 `ps` 外加 `tpgid` 读取——10 个子进程时约 32 ms,仍占 50 ms 间隔的 64%。事件循环依旧大部分时间被阻塞,实测到的问题在修复之后依然存在。
**在 `MacProcessInspector` 内部用短 TTL 缓存 macOS 的表。** 这不需要改接口,但它让陈旧性不可见:调用方无法分辨一个存活答案来自此刻还是来自上一次轮询结束时,而基于陈旧行发出的信号正是 PID 复用围栏要防止的事情。隐式缓存也与仓库偏好显式默认与显式边界的立场冲突。
**在 `snapshot()` 旁保留 `isAlive`。** 这样不必改动发信号的调用点,代价是同一个问题有两种问法,而其中只有一种在循环里是廉价的。这种不对称将不得不在每个调用点重新解释一遍。
**把 `exec` 改成异步,而不是减少读取次数。** 异步的 `execFile` 能让轮询不再阻塞事件循环,但每次轮询仍然 fork N+1 个进程;在繁忙的机器上这是把一次停顿换成了持续的 fork 压力。它在减少读取次数之上仍是值得做的后续项,而不是它的替代。
## Consequences
一次就绪轮询的进程表代价现在与子进程数量无关。在 macOS 上,一次轮询执行一次完整表读取加一次小的 `tpgid` 读取,也就是上表中 0 子进程那一行的代价,对任意子进程数量都成立。
Linux 上查询单个标识的存活,代价从读一个 `stat` 文件变成一次完整的 `/proc` 遍历。每个要查询多个标识的调用方都会把这次遍历摊薄,这正是 `signalProcess` 接收观察而非自行捕获的原因;将来若有调用方确实只需要一次孤立的存活查询,它付出的代价会比过去高。
快照是一个时间点视图,该类型的文档也这样声明。跨 `await` 持有一份快照再据此发信号,会扩大围栏本来要收窄的 PID 复用窗口;`waitForMembers` 每轮重新捕获正是为此。
每个 `ProcessInspector` 实现与测试替身都采用新形态,包括 Windows 检查器和 `dsh-terminal-bash` 的会话替身。此前通过替换 `processTree``processSession``isAlive` 来编排扫描的测试替身,现在替换对应的按问题读取钩子,其编排行为与调用计数保持不变。
同步的 `execFileSync` 边界与固定的 50 ms 轮询间隔未做改动;两者都仍是同一条就绪路径上待办的后续项。
@@ -16,6 +16,41 @@ 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 once, at capture — a `/bin/ps` fork on macOS, a `/proc`
* walk on Linux, a Toolhelp32 enumeration on Windows. Answering {@link tree},
* {@link session}, or {@link alive} never re-reads it, which is what keeps a
* poll's cost independent of how many descendants the running command spawned.
* Windows liveness additionally consults the live process handle, because wait
* state is not a table column there.
*
* A snapshot is a point-in-time view. Take a fresh one per poll or teardown
* pass; a stale one must never decide that a process is still worth signalling.
*/
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,14 +62,19 @@ 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. */
isAlive(identity: ProcessIdentity): boolean
/**
* Read the process table once and answer tree, session, and liveness from it.
* @returns A point-in-time process-table observation.
*/
snapshot(): ProcessSnapshot
signalGroup(pgid: number, signal: SubprocessTerminalSignal): void
signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void
/**
* Signal one exact process identity, fenced against PID reuse.
* @param identity - PID plus start identity to signal.
* @param signal - termination signal to deliver.
* @param observed - observation the identity fence reads; pass one taken for this teardown pass.
*/
signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL', observed: ProcessSnapshot): void
}
/** Testable boundary around filesystem, process-table, and signal syscalls. */
@@ -292,16 +332,14 @@ 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 isAlive(identity: ProcessIdentity): boolean
abstract snapshot(): ProcessSnapshot
signalGroup(pgid: number, signal: SubprocessTerminalSignal): void {
this.internals.kill(-pgid, signal)
}
signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void {
if (this.isAlive(identity)) this.internals.kill(identity.pid, signal)
signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL', observed: ProcessSnapshot): void {
if (observed.alive(identity)) this.internals.kill(identity.pid, signal)
}
}
@@ -309,6 +347,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 +438,34 @@ class LinuxProcessInspector extends PosixProcessInspector {
return false
}
processTree(rootPid: number): ProcessIdentity[] {
const entries = numericEntries(this.internals, '/proc').flatMap((pid) => {
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 }]
})
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 === 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 +483,8 @@ 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)
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 {
@@ -139,7 +139,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
if (this.exited) return
if (this.rootIdentity !== undefined) {
try {
this.inspector.signalProcess(this.rootIdentity, 'SIGKILL')
this.inspector.signalProcess(this.rootIdentity, 'SIGKILL', this.inspector.snapshot())
} catch (_rootExitedDuringHostExit) {
// Exact identity signalling contains both exit races and PID reuse.
}
@@ -152,42 +152,43 @@ 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[]> {
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
}
private signalMembers(members: ProcessIdentity[], signal: 'SIGTERM' | 'SIGKILL'): void {
const observed = this.inspector.snapshot()
for (const member of members) {
try {
this.inspector.signalProcess(member, signal)
this.inspector.signalProcess(member, signal, observed)
} catch (_alreadyExitedDuringSignal) {
// The exact process identity is rechecked; a same-tick exit is success.
}
@@ -197,7 +198,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 +220,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> {
@@ -262,9 +264,9 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
// (the same console-list agent), so the tiers verify the shell's absence
// through the inspector instead of waiting on `done` alone.
const shellGone = (): boolean =>
this.exited || (this.rootIdentity !== undefined && !this.inspector.isAlive(this.rootIdentity))
this.exited || (this.rootIdentity !== undefined && !this.inspector.snapshot().alive(this.rootIdentity))
if (!shellGone() && this.rootIdentity !== undefined) {
this.inspector.signalProcess(this.rootIdentity, 'SIGTERM')
this.inspector.signalProcess(this.rootIdentity, 'SIGTERM', this.inspector.snapshot())
await this.waitForWindowsShellExit()
}
if (!shellGone() && this.rootIdentity === undefined) {
@@ -276,7 +278,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
await Promise.race([this.done.then(() => undefined), delay(this.graceMs)])
}
if (!shellGone() && this.rootIdentity !== undefined) {
this.inspector.signalProcess(this.rootIdentity, 'SIGKILL')
this.inspector.signalProcess(this.rootIdentity, 'SIGKILL', this.inspector.snapshot())
await this.waitForWindowsShellExit()
}
if (!shellGone()) throw new Error(`terminal cleanup failed; surviving pid: ${this.pid}`)
@@ -285,7 +287,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
private async waitForWindowsShellExit(): Promise<void> {
const until = Date.now() + this.graceMs
while (!this.exited && Date.now() < until) {
if (this.rootIdentity !== undefined && !this.inspector.isAlive(this.rootIdentity)) return
if (this.rootIdentity !== undefined && !this.inspector.snapshot().alive(this.rootIdentity)) return
await delay(Math.min(25, Math.max(1, until - Date.now())))
}
}
@@ -315,7 +317,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
if (this.exited) return
/* v8 ignore next -- stopShellWindows() verified the shell is gone or threw;
the identity re-check is a defensive fence for a future caller. */
if (this.rootIdentity !== undefined && this.inspector.isAlive(this.rootIdentity)) return
if (this.rootIdentity !== undefined && this.inspector.snapshot().alive(this.rootIdentity)) return
this.exited = true
this.output.end()
this.outcome.resolve({ exitCode: null, signal: null })
@@ -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,25 +97,27 @@ 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 {
const entries = this.internals.snapshot()
return {
tree: rootPid => windowsProcessTree(entries, rootPid, pid => this.internals.processState(pid)?.started),
// Windows has no POSIX sessions; the shell pid stands in as a pseudo group.
session: () => [],
alive: (identity) => {
// Wait state is a per-handle question, not a Toolhelp32 column, so
// liveness reads the live process object rather than `entries`.
const state = this.internals.processState(identity.pid)
return state?.active === true && state.started === identity.started
},
}
}
signalGroup(pgid: number, signal: SubprocessTerminalSignal): void {
this.internals.taskkill(pgid, signal === 'SIGKILL')
}
signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void {
if (this.isAlive(identity)) this.internals.taskkill(identity.pid, signal === 'SIGKILL')
signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL', observed: ProcessSnapshot): void {
if (observed.alive(identity)) this.internals.taskkill(identity.pid, signal === 'SIGKILL')
}
}
/* jscpd:ignore-end */
@@ -302,9 +302,7 @@ describe('LocalSubprocessRuntime', () => {
const inspector = {
foregroundPgid: () => undefined,
isStdinWaiting: () => false,
processTree: () => [],
processSession: () => [],
isAlive: () => false,
snapshot: () => ({ tree: () => [], session: () => [], alive: () => false }),
signalGroup: () => {},
signalProcess: () => {},
}
@@ -369,9 +367,11 @@ describe('LocalSubprocessRuntime', () => {
;(ctx.subprocess as InstanceType<typeof IsolatedLocalSubprocessRuntime>).terminalInspector = {
foregroundPgid: () => 123,
isStdinWaiting: () => false,
processTree: () => [{ pid: 123, started: 'shell' }, { pid: 124, started: 'child' }],
processSession: () => [],
isAlive: identity => alive.has(identity.pid),
snapshot: () => ({
tree: () => [{ pid: 123, started: 'shell' }, { pid: 124, started: 'child' }],
session: () => [],
alive: 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 })
@@ -68,9 +68,10 @@ function cleanupTree(state: TreeState | undefined, identities: ProcessIdentity[]
return
}
const inspector = createProcessInspector()
const observed = inspector.snapshot()
for (const identity of identities) {
try {
inspector.signalProcess(identity, 'SIGKILL')
inspector.signalProcess(identity, 'SIGKILL', observed)
} catch (_alreadyGone) {
// Exact start identity prevents PID-reuse cleanup from reaching another process.
}
@@ -121,28 +121,31 @@ 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')
inspector.signalProcess({ pid: 10, started: '500' }, 'SIGTERM', observed)
inspector.signalProcess({ pid: 10, started: 'old' }, 'SIGKILL', observed)
expect(fake.kills).toEqual([[-40, 'SIGINT'], [10, 'SIGTERM']])
fake.files.set('/proc/10/stat', stat(10, 20, 30, 40, '500', 1, 'Z'))
expect(inspector.isAlive({ pid: 10, started: '500' })).toBe(false)
inspector.signalProcess({ pid: 10, started: '500' }, 'SIGKILL')
// A zombie is present in the table but never signallable; a fresh capture sees the new state.
const afterExit = inspector.snapshot()
expect(afterExit.alive({ pid: 10, started: '500' })).toBe(false)
inspector.signalProcess({ pid: 10, started: '500' }, 'SIGKILL', afterExit)
expect(fake.kills).toEqual([[-40, 'SIGINT'], [10, 'SIGTERM']])
})
@@ -285,21 +288,22 @@ 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')
inspector.signalProcess({ pid: 11, started: 'Mon Jul 21 10:00:01 2026' }, 'SIGKILL', observed)
inspector.signalProcess({ pid: 12, started: 'missing' }, 'SIGTERM', observed)
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' },
])
@@ -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,18 +72,27 @@ 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)
snapshot(): ProcessSnapshot {
return {
tree: () => this.readTree(),
session: () => this.readSession(),
alive: identity => this.readAlive(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') {
signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL', observed: ProcessSnapshot) {
// 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
if (!observed.alive(identity)) return
this.processes.push([identity.pid, signal])
if (this.removeOnSignal) this.alive.delete(identity.pid)
}
@@ -104,8 +116,8 @@ describe('LocalTerminalHandle', () => {
inspector.alive.add(pty.pid)
inspector.alive.add(first.pid)
const signalProcess = inspector.signalProcess.bind(inspector)
inspector.signalProcess = (identity, signal) => {
signalProcess(identity, signal)
inspector.signalProcess = (identity, signal, observed) => {
signalProcess(identity, signal, observed)
if (identity.pid === pty.pid) {
inspector.members = [first, late]
inspector.alive.add(late.pid)
@@ -135,7 +147,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 +178,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 +270,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 +330,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 +397,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 +526,56 @@ describe('LocalTerminalHandle on Windows', () => {
expect(inspector.processes).toEqual([])
})
})
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)
})
})
@@ -66,7 +66,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,16 +74,16 @@ 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' },
])
expect(inspector.isAlive({ pid: 11, started: 't11' })).toBe(true)
expect(inspector.isAlive({ pid: 11, started: 'stale' })).toBe(false)
expect(inspector.isAlive({ pid: 99, started: 't99' })).toBe(false)
expect(inspector.snapshot().alive({ pid: 11, started: 't11' })).toBe(true)
expect(inspector.snapshot().alive({ pid: 11, started: 'stale' })).toBe(false)
expect(inspector.snapshot().alive({ pid: 99, started: 't99' })).toBe(false)
fake.add({ pid: 12, parentPid: 10 }, 't12', false)
expect(inspector.isAlive({ pid: 12, started: 't12' })).toBe(false)
expect(inspector.snapshot().alive({ pid: 12, started: 't12' })).toBe(false)
})
it('maps SIGKILL to a forced taskkill and other signals to the grace form', () => {
@@ -100,9 +100,9 @@ describe('WindowsProcessInspector (injected internals)', () => {
fake.add({ pid: 10, parentPid: 0 }, 't10')
fake.add({ pid: 11, parentPid: 10 }, 't11', false)
const inspector = new WindowsProcessInspector(fake.internals)
inspector.signalProcess({ pid: 10, started: 't10' }, 'SIGKILL')
inspector.signalProcess({ pid: 11, started: 't11' }, 'SIGKILL')
inspector.signalProcess({ pid: 10, started: 'stale' }, 'SIGTERM')
inspector.signalProcess({ pid: 10, started: 't10' }, 'SIGKILL', inspector.snapshot())
inspector.signalProcess({ pid: 11, started: 't11' }, 'SIGKILL', inspector.snapshot())
inspector.signalProcess({ pid: 10, started: 'stale' }, 'SIGTERM', inspector.snapshot())
expect(fake.kills).toEqual([[10, true]])
})
@@ -130,19 +130,21 @@ 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)
})
it('reports unreadable identities for absent processes and no-ops tree signalling', () => {
const inspector = createWindowsProcessInspector()
expect(inspector.isAlive({ pid: 0x7FFFFFFF, started: 'absent' })).toBe(false)
expect(inspector.snapshot().alive({ pid: 0x7FFFFFFF, started: 'absent' })).toBe(false)
expect(() => { inspector.signalGroup(0x7FFFFFFF, 'SIGKILL') }).not.toThrow()
expect(() => { inspector.signalGroup(0x7FFFFFFF, 'SIGTERM') }).not.toThrow()
expect(() => { inspector.signalGroup(0, 'SIGKILL') }).not.toThrow()
expect(() => { inspector.signalProcess({ pid: 0x7FFFFFFF, started: 'absent' }, 'SIGKILL') }).not.toThrow()
expect(() => {
inspector.signalProcess({ pid: 0x7FFFFFFF, started: 'absent' }, 'SIGKILL', inspector.snapshot())
}).not.toThrow()
})
})
@@ -27,9 +27,13 @@ class FakeInspector implements ProcessInspector {
foregroundPgid() { return this.pgid }
isStdinWaiting() { return this.waiting }
processTree() { return this.members }
processSession() { return [] }
isAlive(identity: ProcessIdentity) { return this.alive.has(identity.pid) }
snapshot() {
return {
tree: () => this.members,
session: () => [],
alive: (identity: ProcessIdentity) => this.alive.has(identity.pid),
}
}
signalGroup(pgid: number, signal: TerminalSignal) {
if (this.throwGroup) throw new Error('group failed')
this.groups.push([pgid, signal])