refactor(runtime): compose consumers over fs and subprocess
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user