| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406 |
- /** 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 { BoundProcessOwner } from './managed-owner.ts'
- import type { ProcessIdentity, ProcessInspector, ProcessSnapshot } from './process-inspector.ts'
- function delay(ms: number, signal?: AbortSignal): Promise<void> {
- return new Promise((resolve) => {
- const finish = (): void => {
- clearTimeout(timer)
- signal?.removeEventListener('abort', finish)
- resolve()
- }
- const timer = setTimeout(finish, ms)
- signal?.addEventListener('abort', finish, { once: true })
- })
- }
- async function raceWithDelay<T, U>(operation: Promise<T>, ms: number, timeout: U): Promise<T | U> {
- const controller = new AbortController()
- try {
- return await Promise.race([
- operation,
- delay(ms, controller.signal).then(() => timeout),
- ])
- } finally {
- controller.abort()
- }
- }
- 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 native managed range or fallback process-session
- * ownership stays below the PTY backend.
- * The seam's terminate() promise — no write, inspection, or signal in flight
- * after settlement — holds here without operation tracking only because every
- * handle call completes synchronously under the hood (node-pty write, ps-based
- * inspection). A first genuinely asynchronous step in any handle call must add
- * the tracking a remote provider needs.
- */
- 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 cleanup: Promise<void> | undefined
- private managedOwnerCleaned = false
- private exited = false
- private trackedDescendants: ProcessIdentity[] = []
- /** The spawned shell's start identity; scans stop adopting members once the root pid no longer carries it. */
- private readonly rootIdentity: ProcessIdentity | undefined
- /**
- * @param terminal - allocated node-pty process.
- * @param inspector - platform process/session operations.
- * @param graceMs - TERM-to-KILL and exit-wait grace.
- * @param platform - host platform; defaults to the running platform, injectable for deterministic tests.
- */
- constructor(
- private readonly terminal: IPty,
- private readonly inspector: ProcessInspector,
- private readonly graceMs: number,
- private readonly platform: NodeJS.Platform = process.platform,
- private readonly managedOwner?: BoundProcessOwner,
- private readonly resolveManagedOutcome?: (outcome: SubprocessOutcome) => SubprocessOutcome,
- ) {
- this.pid = terminal.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 }) => {
- if (this.exited) return
- this.exited = true
- this.output.end()
- const outcome = {
- exitCode: exitSignal === undefined || exitSignal === 0 ? exitCode : null,
- signal: signalName(exitSignal),
- }
- try {
- this.outcome.resolve(this.resolveManagedOutcome?.(outcome) ?? outcome)
- } catch (error) {
- this.outcome.reject(error)
- }
- })
- }
- /** Whether node-pty has not yet published the top-level exit event. */
- get running(): boolean {
- return !this.exited
- }
- // node-pty writes synchronously; 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 write(data: string): Promise<void> {
- if (this.exited) throw new Error('terminal process has exited')
- this.terminal.write(data)
- }
- // 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.inspector.snapshot())
- const processGroupId = this.inspector.foregroundPgid(this.pid)
- if (processGroupId === undefined) return undefined
- return {
- processGroupId,
- inputWaiting: this.inspector.isStdinWaiting(processGroupId, this.pid),
- }
- }
- 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')
- }
- if (this.platform === 'win32') {
- if (signal === 'SIGINT') {
- // Windows has no process-group signalling: a `\x03` input write is the
- // Ctrl-C delivery path conhost turns into a console-wide CTRL_C event
- // for attached processes. node-pty's signal kills throw on Windows, so
- // no signal ever reaches the inspector.
- this.terminal.write('\x03')
- return foreground.processGroupId
- }
- if (signal === 'SIGTSTP' || signal === 'SIGHUP') {
- throw new Error(`signal ${signal} is unsupported on Windows; only SIGINT, SIGTERM, and SIGKILL are available`)
- }
- }
- this.inspector.signalGroup(foreground.processGroupId, signal)
- return foreground.processGroupId
- }
- terminate(): Promise<void> {
- if (this.cleanup !== undefined) return this.cleanup
- const cleanup = this.closeOnce()
- this.cleanup = cleanup
- void cleanup.catch(() => { this.cleanup = undefined })
- return cleanup
- }
- /**
- * Force-terminate the observable session synchronously during Node's exit
- * event. This does not claim quiescence and does not replace terminate().
- */
- terminateForHostExit(): void {
- this.forceStopDescendants()
- this.forceStopShell()
- this.forceStopDescendants()
- this.managedOwner?.terminateForHostExit()
- }
- private forceStopShell(): void {
- if (this.exited) return
- if (this.rootIdentity !== undefined) {
- try {
- this.inspector.signalProcess(this.rootIdentity, 'SIGKILL')
- } catch (_rootExitedDuringHostExit) {
- // Exact identity signalling contains both exit races and PID reuse.
- }
- return
- }
- try {
- this.terminal.kill('SIGKILL')
- } catch (_unidentifiedShellExitedDuringHostExit) {
- // Without a captured identity, node-pty is the only root kill primitive.
- }
- }
- private survivors(members: ProcessIdentity[], observed: ProcessSnapshot): ProcessIdentity[] {
- return members.filter(member => observed.alive(member))
- }
- 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 = 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, observed.session(this.pid)] : [],
- ).filter(member => member.pid !== this.pid), observed)
- return this.trackedDescendants
- }
- private async waitForMembers(members: ProcessIdentity[]): Promise<ProcessIdentity[]> {
- if (members.length === 0) return []
- const until = Date.now() + this.graceMs
- let survivors = this.survivors(members, 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, this.inspector.snapshot())
- }
- return survivors
- }
- private signalMembers(members: ProcessIdentity[], signal: 'SIGTERM' | 'SIGKILL'): void {
- for (const member of members) {
- try {
- // Each signal reads its own identity fence, inside this try: a failed
- // read must cost one target, never the rest of a teardown round.
- this.inspector.signalProcess(member, signal)
- } catch (_alreadyExitedDuringSignal) {
- // The exact process identity is rechecked; a same-tick exit is success.
- }
- }
- }
- private forceStopDescendants(): void {
- let members = this.trackedDescendants
- try {
- members = this.descendants(this.inspector.snapshot())
- } catch (_processTableUnavailableDuringHostExit) {
- // Preserve already-captured identities when a final process-table scan fails.
- }
- this.signalMembers(members, 'SIGKILL')
- }
- 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.inspector.snapshot())
- this.signalMembers(captured, 'SIGTERM')
- const capturedSurvivors = await this.waitForMembers(captured)
- const members = this.unionMembers(capturedSurvivors, this.descendants(this.inspector.snapshot()))
- this.signalMembers(members, 'SIGKILL')
- const survivors = await this.waitForMembers(members)
- const observed = this.inspector.snapshot()
- return this.survivors(this.unionMembers(survivors, this.descendants(observed)), observed)
- }
- private async stopShell(): Promise<void> {
- if (this.platform === 'win32') {
- await this.stopShellWindows()
- return
- }
- 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 stopShellWindows(): Promise<void> {
- // node-pty's Windows kill(signal) throws ("Signals not supported on
- // windows"), and its bare kill() delegates to a console-list agent that
- // fails when the parent has no console. taskkill tree escalation is the
- // teardown path, fenced on the shell's start identity like every
- // descendant; a root identity miss falls back to the bare kill. taskkill
- // termination also does not reliably fire node-pty's exit notification
- // (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))
- if (!shellGone() && this.rootIdentity !== undefined) {
- this.inspector.signalProcess(this.rootIdentity, 'SIGTERM')
- await this.waitForWindowsShellExit()
- }
- if (!shellGone() && this.rootIdentity === undefined) {
- try {
- this.terminal.kill()
- } catch (_topLevelAlreadyExitedDuringKill) {
- // The exit callback is authoritative.
- }
- await Promise.race([this.done.then(() => undefined), delay(this.graceMs)])
- }
- if (!shellGone() && this.rootIdentity !== undefined) {
- this.inspector.signalProcess(this.rootIdentity, 'SIGKILL')
- await this.waitForWindowsShellExit()
- }
- if (!shellGone()) throw new Error(`terminal cleanup failed; surviving pid: ${this.pid}`)
- }
- 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
- await delay(Math.min(25, Math.max(1, until - Date.now())))
- }
- }
- private async closeOnce(): Promise<void> {
- if (this.managedOwner !== undefined) {
- try {
- await this.closeManagedRange(this.managedOwner)
- this.dataDisposable.dispose()
- this.exitDisposable.dispose()
- } finally {
- void this.done.finally(() => { this.cleanupManagedOwner(this.managedOwner as BoundProcessOwner) }).catch(() => {})
- }
- return
- }
- let 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()
- survivors = await this.stopDescendants()
- if (survivors.length > 0) {
- throw new Error(`terminal cleanup failed; surviving pids: ${survivors.map(member => member.pid).join(', ')}`)
- }
- this.settleExitIfGone()
- this.dataDisposable.dispose()
- this.exitDisposable.dispose()
- }
- private cleanupManagedOwner(owner: BoundProcessOwner): void {
- if (this.managedOwnerCleaned) return
- this.managedOwnerCleaned = true
- owner.cleanup?.()
- }
- private async closeManagedRange(owner: BoundProcessOwner): Promise<void> {
- owner.signal('SIGTERM')
- const observation = owner.waitForExit()
- const first = await raceWithDelay(observation.then(
- () => ({ kind: 'stopped' as const }),
- (error: unknown) => ({ kind: 'failed' as const, error }),
- ), this.graceMs, { kind: 'timeout' as const })
- if (first.kind !== 'stopped') {
- owner.signal('SIGKILL')
- if (first.kind === 'failed') {
- // The observation failure is still authoritative, but force cleanup
- // and a fresh final observation must be attempted before exposing it.
- try {
- await owner.waitForExit()
- } catch (finalError: unknown) {
- throw new AggregateError([first.error, finalError], 'terminal managed-range cleanup failed')
- }
- throw first.error
- }
- await observation
- }
- if (!this.exited) {
- await raceWithDelay(this.done.then(() => undefined), this.graceMs, undefined)
- }
- if (!this.exited) throw new Error(`terminal cleanup failed; surviving pid: ${this.pid}`)
- }
- private settleExitIfGone(): void {
- // An externally taskkilled Windows shell may never fire node-pty's exit
- // notification (its console-list agent fails without a parent console),
- // which would leave `done` — and every consumer awaiting it — unsettled
- // forever. Teardown has just verified the shell's absence through the
- // inspector, so a missing exit event is itself the outcome.
- if (this.platform !== 'win32') return
- 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
- this.exited = true
- this.output.end()
- this.outcome.resolve({ exitCode: null, signal: null })
- }
- }
|