refactor(runtime): compose consumers over fs and subprocess

This commit is contained in:
Tianyi Cui
2026-07-28 23:00:00 +08:00
parent 987d477cfc
commit 8917ff8ef4
116 changed files with 2828 additions and 1122 deletions
@@ -7,11 +7,26 @@
* @module @deepseek-ai/dsh-subprocess-local
*/
import { constants } from 'node:fs'
import { mkdtempSync } from 'node:fs'
import { access, rm, stat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { delimiter, extname, isAbsolute, join } from 'node:path'
import { Context } from 'cordis'
import * as nodePty from 'node-pty'
import type { IPtyForkOptions } from 'node-pty'
import { SubprocessService } from '@deepseek-ai/dsh-subprocess'
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { spawnSubprocess } from './spawn.ts'
import type {
SubprocessHandle,
SubprocessSpawnSpec,
SubprocessTerminalHandle,
SubprocessTerminalSpawnSpec,
} from '@deepseek-ai/dsh-subprocess'
import { childEnv, spawnSubprocess } from './spawn.ts'
import type { SpawnInternals } from './spawn.ts'
import { createProcessInspector } from './process-inspector.ts'
import type { ProcessInspector } from './process-inspector.ts'
import { LocalTerminalHandle } from './terminal.ts'
/**
* Local subprocess service: detached process trees, Node-shaped stdio
@@ -20,10 +35,16 @@ import type { SpawnInternals } from './spawn.ts'
* SIGTERM→grace→SIGKILL escalation.
*/
export class LocalSubprocessService extends SubprocessService {
readonly cwd = process.cwd()
readonly runtimeRoot = mkdtempSync(join(tmpdir(), 'dsh-subprocess-runtime-'))
/** Live handles retained only so disposal can terminate and join them. */
private live = new Set<SubprocessHandle>()
/** Live terminal sessions retained through whole-session quiescence. */
private terminals = new Set<SubprocessTerminalHandle>()
/** Test seam: spill and platform knobs forwarded to spawnSubprocess. */
internals: SpawnInternals = {}
/** Test seam for platform process inspection; production resolves lazily on terminal spawn. */
terminalInspector: ProcessInspector | undefined
constructor(ctx: Context) {
super(ctx)
@@ -37,11 +58,58 @@ export class LocalSubprocessService extends SubprocessService {
// Spawn-failure rejections already settled and left the live set.
pending.push(handle.done.catch(() => {}).then(() => handle.waitForExit()))
}
for (const terminal of this.terminals) {
terminal.terminate()
// Cleanup may reject before the top-level process exits (for example,
// an identity-fenced descendant survives escalation). Await the cleanup
// transaction directly so disposal reports that failure rather than
// waiting forever on `done`.
pending.push(terminal.waitForExit())
}
this.live.clear()
this.terminals.clear()
await Promise.all(pending)
await rm(this.runtimeRoot, { recursive: true, force: true })
}, 'local subprocess teardown')
}
async resolveExecutable(
command: string,
env?: Readonly<Record<string, string>>,
signal?: AbortSignal,
): Promise<string> {
if (command.length === 0) throw new Error('subprocess-local: executable must be non-empty')
signal?.throwIfAborted()
const environment = childEnv(env)
const absolute = isAbsolute(command)
const candidates = absolute ? [command] : this.executableCandidates(command, environment)
for (const candidate of candidates) {
signal?.throwIfAborted()
try {
const info = await stat(candidate)
if (!info.isFile()) continue
await access(candidate, constants.X_OK)
signal?.throwIfAborted()
return candidate
} catch {
// Try the next PATH candidate; the final miss receives one stable error.
}
}
signal?.throwIfAborted()
throw new Error(absolute
? `subprocess-local: command ${JSON.stringify(command)} is not an executable file`
: `subprocess-local: command ${JSON.stringify(command)} was not found on PATH`)
}
private executableCandidates(command: string, env: NodeJS.ProcessEnv): string[] {
const path = env.PATH ?? ''
const extensions = process.platform === 'win32' && extname(command) === ''
? (env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';')
: ['']
return path.split(delimiter).flatMap(directory =>
directory === '' ? [] : extensions.map(extension => join(directory, command + extension)))
}
spawn(spec: SubprocessSpawnSpec): SubprocessHandle {
const handle = spawnSubprocess(spec, this.internals)
this.live.add(handle)
@@ -54,6 +122,38 @@ export class LocalSubprocessService extends SubprocessService {
handle.done.then(release, release)
return handle
}
// Local PTY allocation is synchronous, but the provider seam permits remote asynchronous allocation.
// eslint-disable-next-line @typescript-eslint/require-await
async spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle> {
const file = spec.argv[0]
if (file === undefined || file.length === 0) {
throw new Error('subprocess-local: terminal argv must contain a program')
}
for (const [name, value] of [['rows', spec.rows], ['cols', spec.cols], ['graceMs', spec.graceMs]] as const) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`subprocess-local: terminal ${name} must be a positive safe integer`)
}
}
spec.signal?.throwIfAborted()
const options: IPtyForkOptions = {
name: 'dumb',
rows: spec.rows,
cols: spec.cols,
cwd: spec.cwd,
env: childEnv(spec.env),
}
const inspector = this.terminalInspector ?? createProcessInspector()
const terminal = nodePty.spawn(file, [...spec.argv.slice(1)], options)
const handle = new LocalTerminalHandle(terminal, inspector, spec.graceMs, spec.signal)
this.terminals.add(handle)
const release = async (): Promise<void> => {
await handle.waitForExit()
this.terminals.delete(handle)
}
void handle.done.then(release, release).catch(() => {})
return handle
}
}
export default LocalSubprocessService
@@ -0,0 +1,331 @@
/** Platform process-table inspection for terminal readiness, signals, and teardown. */
import { closeSync, openSync, readFileSync, readdirSync, readSync } from 'node:fs'
import { execFileSync } from 'node:child_process'
import type { SubprocessTerminalSignal } from '@deepseek-ai/dsh-subprocess'
/** PID plus start identity, preventing teardown escalation after PID reuse. */
export interface ProcessIdentity {
pid: number
started: string
}
/** Injectable OS process operations used by one local PTY session. */
export interface ProcessInspector {
foregroundPgid(shellPid: number): number | undefined
isStdinWaiting(pgid: number): boolean
/** Return the root and its current transitive descendants, children first. */
processTree(rootPid: number): ProcessIdentity[]
/** Return whether the exact identity remains a non-quiescent process. */
isAlive(identity: ProcessIdentity): boolean
signalGroup(pgid: number, signal: SubprocessTerminalSignal): void
signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void
}
/** Testable boundary around filesystem, process-table, and signal syscalls. */
export interface ProcessInspectorInternals {
readFile(path: string): string
readDir(path: string): string[]
open(path: string): number
read(fd: number, buffer: Buffer, length: number, position: number): number
close(fd: number): void
exec(file: string, args: string[]): string
kill(pid: number, signal: NodeJS.Signals): void
}
/* v8 ignore start -- thin OS bindings; injected logic is unit-tested and real platform composition exercises them. */
const DEFAULT_INTERNALS: ProcessInspectorInternals = {
readFile: path => readFileSync(path, 'utf8'),
readDir: path => readdirSync(path),
open: path => openSync(path, 'r'),
read: (fd, buffer, length, position) => readSync(fd, buffer, 0, length, position),
close: closeSync,
exec: (file, args) => execFileSync(file, args, { encoding: 'utf8' }),
kill: (pid, signal) => process.kill(pid, signal),
}
/* v8 ignore stop */
interface ProcStat {
pid: number
parentPid: number
pgrp: number
session: number
state: string
tpgid: number
started: string
}
/**
* Parse fields used from Linux `/proc/<pid>/stat`, including parenthesized comm text.
* @param text - complete stat line.
* @returns Parsed identity/group fields, or undefined for malformed input.
*/
export function parseProcStat(text: string): ProcStat | undefined {
const open = text.indexOf('(')
const close = text.lastIndexOf(')')
if (open <= 0 || close <= open) return undefined
const pid = Number(text.slice(0, open).trim())
const rest = text.slice(close + 2).trim().split(/\s+/)
const state = rest[0] || ''
const parentPid = Number(rest[1])
const pgrp = Number(rest[2])
const session = Number(rest[3])
const tpgid = Number(rest[5])
const started = rest[19]
if (![pid, parentPid, pgrp, session, tpgid].every(Number.isSafeInteger)
|| state.length !== 1 || started === undefined) return undefined
return { pid, parentPid, pgrp, session, state, tpgid, started }
}
function readLinuxStat(internals: ProcessInspectorInternals, pid: number): ProcStat | undefined {
try {
return parseProcStat(internals.readFile(`/proc/${pid}/stat`))
} catch (_unreadableProcEntry) {
return undefined
}
}
function numericEntries(internals: ProcessInspectorInternals, path: string): number[] {
try {
return internals.readDir(path).filter(entry => /^\d+$/.test(entry)).map(Number)
} catch (_unreadableProcDirectory) {
return []
}
}
interface SyscallInfo {
number: number
args: number[]
}
function readSyscall(internals: ProcessInspectorInternals, pid: number, tid: number): SyscallInfo | undefined {
try {
const text = internals.readFile(`/proc/${pid}/task/${tid}/syscall`).trim()
if (text === 'running' || text.startsWith('-1 ')) return undefined
const fields = text.split(/\s+/)
const number = Number(fields[0])
const args = fields.slice(1, 7).map(field => Number.parseInt(field, 16))
if (!Number.isSafeInteger(number) || args.some(value => !Number.isSafeInteger(value))) return undefined
return { number, args }
} catch (_unreadableSyscall) {
return undefined
}
}
function readMemory(
internals: ProcessInspectorInternals,
pid: number,
address: number,
length: number,
): Buffer | undefined {
let fd: number | undefined
try {
fd = internals.open(`/proc/${pid}/mem`)
const buffer = Buffer.alloc(length)
const count = internals.read(fd, buffer, length, address)
return buffer.subarray(0, count)
} catch (_unreadableProcessMemory) {
return undefined
} finally {
if (fd !== undefined) internals.close(fd)
}
}
function fdSetHasStdin(internals: ProcessInspectorInternals, pid: number, address: number): boolean {
return address !== 0 && (readMemory(internals, pid, address, 8)?.[0] ?? 0) % 2 === 1
}
function pollHasStdin(
internals: ProcessInspectorInternals,
pid: number,
address: number,
count: number,
): boolean {
if (address === 0 || count <= 0) return false
const memory = readMemory(internals, pid, address, Math.min(count, 1024) * 8)
if (memory === undefined) return false
for (let offset = 0; offset + 8 <= memory.length; offset += 8) {
if (memory.readInt32LE(offset) === 0 && (memory.readInt16LE(offset + 4) & 0x001) !== 0) return true
}
return false
}
function epollHasStdin(internals: ProcessInspectorInternals, pid: number, epfd: number): boolean {
try {
return internals.readFile(`/proc/${pid}/fdinfo/${epfd}`)
.split('\n')
.some(line => /^tfd:\s+0\b/.test(line.trim()))
} catch (_unreadableFdInfo) {
return false
}
}
interface SyscallTable {
read: number
select?: number
pselect: number
poll?: number
ppoll: number
epollWait?: number
epollPwait: number
}
const SYSCALLS: Partial<Record<NodeJS.Architecture, SyscallTable>> = {
x64: { read: 0, select: 23, pselect: 270, poll: 7, ppoll: 271, epollWait: 232, epollPwait: 281 },
arm64: { read: 63, pselect: 72, ppoll: 73, epollPwait: 22 },
}
function syscallWaitsOnStdin(
internals: ProcessInspectorInternals,
pid: number,
syscall: SyscallInfo,
table: SyscallTable,
): boolean {
const [a0 = 0, a1 = 0, a2 = 0] = syscall.args
if (syscall.number === table.read) return a0 === 0
if (syscall.number === table.select || syscall.number === table.pselect) {
return a0 >= 1 && fdSetHasStdin(internals, pid, a1)
}
if (syscall.number === table.poll || syscall.number === table.ppoll) {
return a1 >= 1 && pollHasStdin(internals, pid, a0, a1)
}
if (syscall.number === table.epollWait || syscall.number === table.epollPwait) {
return a2 >= 1 && epollHasStdin(internals, pid, a0)
}
return false
}
abstract class PosixProcessInspector implements ProcessInspector {
constructor(protected readonly internals: ProcessInspectorInternals) {}
abstract foregroundPgid(shellPid: number): number | undefined
abstract isStdinWaiting(pgid: number): boolean
abstract processTree(rootPid: number): ProcessIdentity[]
abstract isAlive(identity: ProcessIdentity): boolean
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)
}
}
interface ProcessTreeEntry extends ProcessIdentity {
parentPid: number
}
function processTree(entries: ProcessTreeEntry[], rootPid: number): ProcessIdentity[] {
const byPid = new Map(entries.map(entry => [entry.pid, entry]))
const root = byPid.get(rootPid)
if (root === undefined) return []
const byParent = new Map<number, ProcessTreeEntry[]>()
for (const entry of entries) {
const children = byParent.get(entry.parentPid) ?? []
children.push(entry)
byParent.set(entry.parentPid, children)
}
const visited = new Set<number>()
const result: ProcessIdentity[] = []
const visit = (entry: ProcessTreeEntry): void => {
if (visited.has(entry.pid)) return
visited.add(entry.pid)
for (const child of byParent.get(entry.pid) ?? []) visit(child)
result.push({ pid: entry.pid, started: entry.started })
}
visit(root)
return result
}
class LinuxProcessInspector extends PosixProcessInspector {
constructor(
private readonly arch: NodeJS.Architecture,
internals: ProcessInspectorInternals,
) {
super(internals)
}
foregroundPgid(shellPid: number): number | undefined {
const tpgid = readLinuxStat(this.internals, shellPid)?.tpgid
return tpgid !== undefined && tpgid > 0 ? tpgid : undefined
}
isStdinWaiting(pgid: number): boolean {
const table = SYSCALLS[this.arch]
if (table === undefined) return false
for (const pid of numericEntries(this.internals, '/proc')) {
if (readLinuxStat(this.internals, pid)?.pgrp !== pgid) continue
for (const tid of numericEntries(this.internals, `/proc/${pid}/task`)) {
const syscall = readSyscall(this.internals, pid, tid)
if (syscall !== undefined && syscallWaitsOnStdin(this.internals, pid, syscall, table)) return true
}
}
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)
}
isAlive(identity: ProcessIdentity): boolean {
const stat = readLinuxStat(this.internals, identity.pid)
return stat?.started === identity.started && !/^[ZXx]$/.test(stat.state)
}
}
interface PsEntry extends ProcessTreeEntry {}
function macProcessTable(internals: ProcessInspectorInternals): PsEntry[] {
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] }]
})
}
class MacProcessInspector extends PosixProcessInspector {
foregroundPgid(shellPid: number): number | undefined {
try {
const value = Number(this.internals.exec('/bin/ps', ['-o', 'tpgid=', '-p', String(shellPid)]).trim())
return Number.isSafeInteger(value) && value > 0 ? value : undefined
} catch (_missingProcess) {
return undefined
}
}
isStdinWaiting(_pgid: number): boolean {
return false
}
processTree(rootPid: number): ProcessIdentity[] {
return processTree(macProcessTable(this.internals), rootPid)
}
isAlive(identity: ProcessIdentity): boolean {
return macProcessTable(this.internals).some(entry => entry.pid === identity.pid && entry.started === identity.started)
}
}
/**
* Create the supported platform inspector or fail at plugin load.
* @param platform - target Node platform.
* @param arch - target CPU architecture for Linux syscall numbers.
* @param internals - filesystem/process boundary, injectable for deterministic tests.
* @returns Platform process inspector.
*/
export function createProcessInspector(
platform: NodeJS.Platform = process.platform,
arch: NodeJS.Architecture = process.arch,
internals: ProcessInspectorInternals = DEFAULT_INTERNALS,
): ProcessInspector {
if (platform === 'linux') return new LinuxProcessInspector(arch, internals)
if (platform === 'darwin') return new MacProcessInspector(internals)
throw new Error(`subprocess-local: terminal inspection is unsupported on platform ${platform}`)
}
@@ -0,0 +1,226 @@
/** Local node-pty terminal-process implementation for the subprocess seam. */
import { Buffer } from 'node:buffer'
import { constants } from 'node:os'
import { PassThrough } from 'node:stream'
import type { IDisposable, IPty } from 'node-pty'
import type {
SubprocessOutcome,
SubprocessTerminalForeground,
SubprocessTerminalHandle,
SubprocessTerminalSignal,
} from '@deepseek-ai/dsh-subprocess'
import type { ProcessIdentity, ProcessInspector } from './process-inspector.ts'
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
function signalName(number: number | undefined): NodeJS.Signals | null {
if (number === undefined || number === 0) return null
for (const [name, value] of Object.entries(constants.signals)) {
if (value === number) return name as NodeJS.Signals
}
return null
}
/** A local terminal whose process-session ownership stays below the PTY backend. */
export class LocalTerminalHandle implements SubprocessTerminalHandle {
readonly pid: number
readonly output = new PassThrough()
readonly done: Promise<SubprocessOutcome>
private readonly outcome = Promise.withResolvers<SubprocessOutcome>()
private readonly dataDisposable: IDisposable
private readonly exitDisposable: IDisposable
private exited = false
private termination: Promise<void> | undefined
private removeAbort: (() => void) | undefined
/**
* @param terminal - allocated node-pty process.
* @param inspector - platform process/session operations.
* @param graceMs - TERM-to-KILL and exit-wait grace.
* @param signal - optional lifetime cancellation.
*/
constructor(
private readonly terminal: IPty,
private readonly inspector: ProcessInspector,
private readonly graceMs: number,
signal?: AbortSignal,
) {
this.pid = terminal.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 }) => {
if (this.exited) return
this.exited = true
this.output.end()
this.outcome.resolve({
exitCode: exitSignal === undefined || exitSignal === 0 ? exitCode : null,
signal: signalName(exitSignal),
})
this.terminate()
})
if (signal !== undefined) {
const onAbort = (): void => { this.terminate() }
signal.addEventListener('abort', onAbort, { once: true })
this.removeAbort = () => { signal.removeEventListener('abort', onAbort) }
if (signal.aborted) this.terminate()
}
}
// node-pty writes synchronously; the seam returns a promise for remote transports.
// eslint-disable-next-line @typescript-eslint/require-await
async write(data: Uint8Array): Promise<void> {
if (this.exited) throw new Error('terminal process has exited')
let text: string
try {
text = new TextDecoder('utf-8', { fatal: true }).decode(data)
} catch (error: unknown) {
throw new Error('terminal input must be valid UTF-8', { cause: error })
}
this.terminal.write(text)
}
// Local inspection is synchronous; the seam returns a promise for remote transports.
// eslint-disable-next-line @typescript-eslint/require-await
async inspectForeground(): Promise<SubprocessTerminalForeground | undefined> {
const processGroupId = this.inspector.foregroundPgid(this.pid)
if (processGroupId === undefined) return undefined
return {
processGroupId,
inputWaiting: this.inspector.isStdinWaiting(processGroupId),
}
}
async signalForeground(signal: SubprocessTerminalSignal): Promise<number> {
const foreground = await this.inspectForeground()
if (foreground === undefined) {
throw new Error(`cannot resolve foreground process group for terminal ${this.pid}`)
}
if (signal === 'SIGKILL' && foreground.processGroupId === this.pid) {
throw new Error('refusing to SIGKILL the terminal shell; terminate the terminal session instead')
}
this.inspector.signalGroup(foreground.processGroupId, signal)
return foreground.processGroupId
}
terminate(): void {
this.termination ??= this.closeOnce().catch((error: unknown) => {
this.termination = undefined
throw error
})
void this.termination.catch(() => {})
}
async waitForExit(signal?: AbortSignal): Promise<boolean> {
// A caller may begin waiting before the top-level process exits. The exit
// callback starts descendant cleanup in the same turn, so resolve that
// eventual transaction after `done` instead of snapshotting only `done`.
const quiescence = this.termination ?? this.done.then(() => this.termination)
if (signal === undefined) {
await quiescence
return true
}
if (signal.aborted) return false
return await new Promise<boolean>((resolve, reject) => {
const onAbort = (): void => { cleanup(); resolve(false) }
const cleanup = (): void => { signal.removeEventListener('abort', onAbort) }
signal.addEventListener('abort', onAbort, { once: true })
void quiescence.then(
() => { cleanup(); resolve(true) },
(error: unknown) => {
cleanup()
// The owned cleanup transaction only throws Error diagnostics.
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
reject(error)
},
)
})
}
private survivors(members: ProcessIdentity[]): ProcessIdentity[] {
return members.filter(member => this.inspector.isAlive(member))
}
private descendants(): ProcessIdentity[] {
return this.inspector.processTree(this.pid).filter(member => member.pid !== this.pid)
}
private async waitForMembers(members: ProcessIdentity[]): Promise<ProcessIdentity[]> {
const until = Date.now() + this.graceMs
let survivors = this.survivors(members)
while (survivors.length > 0 && Date.now() < until) {
await delay(Math.min(25, Math.max(1, until - Date.now())))
survivors = this.survivors(members)
}
return survivors
}
private signalMembers(members: ProcessIdentity[], signal: 'SIGTERM' | 'SIGKILL'): void {
for (const member of members) {
try {
this.inspector.signalProcess(member, signal)
} catch (_alreadyExitedDuringSignal) {
// The exact process identity is rechecked; a same-tick exit is success.
}
}
}
private unionMembers(...groups: ProcessIdentity[][]): ProcessIdentity[] {
const members: ProcessIdentity[] = []
const seen = new Set<string>()
for (const group of groups) {
for (const member of group) {
const key = `${member.pid}:${member.started}`
if (seen.has(key)) continue
seen.add(key)
members.push(member)
}
}
return members
}
private async stopDescendants(): Promise<ProcessIdentity[]> {
const captured = this.descendants()
this.signalMembers(captured, 'SIGTERM')
const capturedSurvivors = await this.waitForMembers(captured)
const members = this.unionMembers(capturedSurvivors, this.descendants())
this.signalMembers(members, 'SIGKILL')
const survivors = await this.waitForMembers(members)
return this.survivors(this.unionMembers(survivors, this.descendants()))
}
private async stopShell(): Promise<void> {
if (!this.exited) {
try {
this.terminal.kill('SIGTERM')
} catch (_topLevelAlreadyExitedDuringTerm) {
// The exit callback is authoritative.
}
await Promise.race([this.done.then(() => undefined), delay(this.graceMs)])
}
if (!this.exited) {
try {
this.terminal.kill('SIGKILL')
} catch (_topLevelAlreadyExitedDuringKill) {
// The exit callback is authoritative.
}
await Promise.race([this.done.then(() => undefined), delay(this.graceMs)])
}
if (!this.exited) throw new Error(`terminal cleanup failed; surviving pid: ${this.pid}`)
}
private async closeOnce(): Promise<void> {
const survivors = await this.stopDescendants()
if (survivors.length > 0) {
throw new Error(`terminal cleanup failed; surviving pids: ${survivors.map(member => member.pid).join(', ')}`)
}
await this.stopShell()
this.removeAbort?.()
this.removeAbort = undefined
this.dataDisposable.dispose()
this.exitDisposable.dispose()
}
}