terminal.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  1. /** Local node-pty terminal-process implementation for the subprocess seam. */
  2. import { Buffer } from 'node:buffer'
  3. import { constants } from 'node:os'
  4. import { PassThrough } from 'node:stream'
  5. import type { IDisposable, IPty } from 'node-pty'
  6. import type {
  7. SubprocessOutcome,
  8. SubprocessTerminalForeground,
  9. SubprocessTerminalHandle,
  10. SubprocessTerminalSignal,
  11. } from '@deepseek-ai/dsh-subprocess'
  12. import type { BoundProcessOwner } from './managed-owner.ts'
  13. import type { ProcessIdentity, ProcessInspector, ProcessSnapshot } from './process-inspector.ts'
  14. function delay(ms: number, signal?: AbortSignal): Promise<void> {
  15. return new Promise((resolve) => {
  16. const finish = (): void => {
  17. clearTimeout(timer)
  18. signal?.removeEventListener('abort', finish)
  19. resolve()
  20. }
  21. const timer = setTimeout(finish, ms)
  22. signal?.addEventListener('abort', finish, { once: true })
  23. })
  24. }
  25. async function raceWithDelay<T, U>(operation: Promise<T>, ms: number, timeout: U): Promise<T | U> {
  26. const controller = new AbortController()
  27. try {
  28. return await Promise.race([
  29. operation,
  30. delay(ms, controller.signal).then(() => timeout),
  31. ])
  32. } finally {
  33. controller.abort()
  34. }
  35. }
  36. function signalName(number: number | undefined): NodeJS.Signals | null {
  37. if (number === undefined || number === 0) return null
  38. for (const [name, value] of Object.entries(constants.signals)) {
  39. if (value === number) return name as NodeJS.Signals
  40. }
  41. return null
  42. }
  43. /**
  44. * A local terminal whose native managed range or fallback process-session
  45. * ownership stays below the PTY backend.
  46. * The seam's terminate() promise — no write, inspection, or signal in flight
  47. * after settlement — holds here without operation tracking only because every
  48. * handle call completes synchronously under the hood (node-pty write, ps-based
  49. * inspection). A first genuinely asynchronous step in any handle call must add
  50. * the tracking a remote provider needs.
  51. */
  52. export class LocalTerminalHandle implements SubprocessTerminalHandle {
  53. readonly pid: number
  54. readonly output = new PassThrough()
  55. readonly done: Promise<SubprocessOutcome>
  56. private readonly outcome = Promise.withResolvers<SubprocessOutcome>()
  57. private readonly dataDisposable: IDisposable
  58. private readonly exitDisposable: IDisposable
  59. private cleanup: Promise<void> | undefined
  60. private managedOwnerCleaned = false
  61. private exited = false
  62. private trackedDescendants: ProcessIdentity[] = []
  63. /** The spawned shell's start identity; scans stop adopting members once the root pid no longer carries it. */
  64. private readonly rootIdentity: ProcessIdentity | undefined
  65. /**
  66. * @param terminal - allocated node-pty process.
  67. * @param inspector - platform process/session operations.
  68. * @param graceMs - TERM-to-KILL and exit-wait grace.
  69. * @param platform - host platform; defaults to the running platform, injectable for deterministic tests.
  70. */
  71. constructor(
  72. private readonly terminal: IPty,
  73. private readonly inspector: ProcessInspector,
  74. private readonly graceMs: number,
  75. private readonly platform: NodeJS.Platform = process.platform,
  76. private readonly managedOwner?: BoundProcessOwner,
  77. private readonly resolveManagedOutcome?: (outcome: SubprocessOutcome) => SubprocessOutcome,
  78. ) {
  79. this.pid = terminal.pid
  80. this.rootIdentity = inspector.snapshot().tree(this.pid).find(member => member.pid === this.pid)
  81. this.done = this.outcome.promise
  82. this.dataDisposable = terminal.onData((data) => { this.output.write(Buffer.from(data, 'utf8')) })
  83. this.exitDisposable = terminal.onExit(({ exitCode, signal: exitSignal }) => {
  84. if (this.exited) return
  85. this.exited = true
  86. this.output.end()
  87. const outcome = {
  88. exitCode: exitSignal === undefined || exitSignal === 0 ? exitCode : null,
  89. signal: signalName(exitSignal),
  90. }
  91. try {
  92. this.outcome.resolve(this.resolveManagedOutcome?.(outcome) ?? outcome)
  93. } catch (error) {
  94. this.outcome.reject(error)
  95. }
  96. })
  97. }
  98. /** Whether node-pty has not yet published the top-level exit event. */
  99. get running(): boolean {
  100. return !this.exited
  101. }
  102. // node-pty writes synchronously; the seam returns a promise for remote transports.
  103. // oxlint-disable-next-line typescript/require-await -- Preserve promise rejection semantics at the async provider contract.
  104. async write(data: string): Promise<void> {
  105. if (this.exited) throw new Error('terminal process has exited')
  106. this.terminal.write(data)
  107. }
  108. // Local inspection is synchronous; the seam returns a promise for remote transports.
  109. // oxlint-disable-next-line typescript/require-await -- Preserve promise rejection semantics at the async provider contract.
  110. async inspectForeground(): Promise<SubprocessTerminalForeground | undefined> {
  111. this.descendants(this.inspector.snapshot())
  112. const processGroupId = this.inspector.foregroundPgid(this.pid)
  113. if (processGroupId === undefined) return undefined
  114. return {
  115. processGroupId,
  116. inputWaiting: this.inspector.isStdinWaiting(processGroupId, this.pid),
  117. }
  118. }
  119. async signalForeground(signal: SubprocessTerminalSignal): Promise<number> {
  120. const foreground = await this.inspectForeground()
  121. if (foreground === undefined) {
  122. throw new Error(`cannot resolve foreground process group for terminal ${this.pid}`)
  123. }
  124. if (signal === 'SIGKILL' && foreground.processGroupId === this.pid) {
  125. throw new Error('refusing to SIGKILL the terminal shell; terminate the terminal session instead')
  126. }
  127. if (this.platform === 'win32') {
  128. if (signal === 'SIGINT') {
  129. // Windows has no process-group signalling: a `\x03` input write is the
  130. // Ctrl-C delivery path conhost turns into a console-wide CTRL_C event
  131. // for attached processes. node-pty's signal kills throw on Windows, so
  132. // no signal ever reaches the inspector.
  133. this.terminal.write('\x03')
  134. return foreground.processGroupId
  135. }
  136. if (signal === 'SIGTSTP' || signal === 'SIGHUP') {
  137. throw new Error(`signal ${signal} is unsupported on Windows; only SIGINT, SIGTERM, and SIGKILL are available`)
  138. }
  139. }
  140. this.inspector.signalGroup(foreground.processGroupId, signal)
  141. return foreground.processGroupId
  142. }
  143. terminate(): Promise<void> {
  144. if (this.cleanup !== undefined) return this.cleanup
  145. const cleanup = this.closeOnce()
  146. this.cleanup = cleanup
  147. void cleanup.catch(() => { this.cleanup = undefined })
  148. return cleanup
  149. }
  150. /**
  151. * Force-terminate the observable session synchronously during Node's exit
  152. * event. This does not claim quiescence and does not replace terminate().
  153. */
  154. terminateForHostExit(): void {
  155. this.forceStopDescendants()
  156. this.forceStopShell()
  157. this.forceStopDescendants()
  158. this.managedOwner?.terminateForHostExit()
  159. }
  160. private forceStopShell(): void {
  161. if (this.exited) return
  162. if (this.rootIdentity !== undefined) {
  163. try {
  164. this.inspector.signalProcess(this.rootIdentity, 'SIGKILL')
  165. } catch (_rootExitedDuringHostExit) {
  166. // Exact identity signalling contains both exit races and PID reuse.
  167. }
  168. return
  169. }
  170. try {
  171. this.terminal.kill('SIGKILL')
  172. } catch (_unidentifiedShellExitedDuringHostExit) {
  173. // Without a captured identity, node-pty is the only root kill primitive.
  174. }
  175. }
  176. private survivors(members: ProcessIdentity[], observed: ProcessSnapshot): ProcessIdentity[] {
  177. return members.filter(member => observed.alive(member))
  178. }
  179. private descendants(observed: ProcessSnapshot): ProcessIdentity[] {
  180. // Adopt newly scanned members only while the numeric root pid provably
  181. // still carries the spawned shell's start identity: after the shell dies,
  182. // a recycled pid's tree and session must not donate an unrelated
  183. // process's children to this session's signalling. Already-adopted
  184. // members keep their own start identities, which every signal rechecks.
  185. const tree = observed.tree(this.pid)
  186. const root = tree.find(member => member.pid === this.pid)
  187. const rootVerified = this.rootIdentity !== undefined
  188. && root !== undefined
  189. && root.started === this.rootIdentity.started
  190. this.trackedDescendants = this.survivors(this.unionMembers(
  191. this.trackedDescendants,
  192. ...rootVerified ? [tree, observed.session(this.pid)] : [],
  193. ).filter(member => member.pid !== this.pid), observed)
  194. return this.trackedDescendants
  195. }
  196. private async waitForMembers(members: ProcessIdentity[]): Promise<ProcessIdentity[]> {
  197. if (members.length === 0) return []
  198. const until = Date.now() + this.graceMs
  199. let survivors = this.survivors(members, this.inspector.snapshot())
  200. while (survivors.length > 0 && Date.now() < until) {
  201. await delay(Math.min(25, Math.max(1, until - Date.now())))
  202. survivors = this.survivors(members, this.inspector.snapshot())
  203. }
  204. return survivors
  205. }
  206. private signalMembers(members: ProcessIdentity[], signal: 'SIGTERM' | 'SIGKILL'): void {
  207. for (const member of members) {
  208. try {
  209. // Each signal reads its own identity fence, inside this try: a failed
  210. // read must cost one target, never the rest of a teardown round.
  211. this.inspector.signalProcess(member, signal)
  212. } catch (_alreadyExitedDuringSignal) {
  213. // The exact process identity is rechecked; a same-tick exit is success.
  214. }
  215. }
  216. }
  217. private forceStopDescendants(): void {
  218. let members = this.trackedDescendants
  219. try {
  220. members = this.descendants(this.inspector.snapshot())
  221. } catch (_processTableUnavailableDuringHostExit) {
  222. // Preserve already-captured identities when a final process-table scan fails.
  223. }
  224. this.signalMembers(members, 'SIGKILL')
  225. }
  226. private unionMembers(...groups: ProcessIdentity[][]): ProcessIdentity[] {
  227. const members: ProcessIdentity[] = []
  228. const seen = new Set<string>()
  229. for (const group of groups) {
  230. for (const member of group) {
  231. const key = `${member.pid}:${member.started}`
  232. if (seen.has(key)) continue
  233. seen.add(key)
  234. members.push(member)
  235. }
  236. }
  237. return members
  238. }
  239. private async stopDescendants(): Promise<ProcessIdentity[]> {
  240. const captured = this.descendants(this.inspector.snapshot())
  241. this.signalMembers(captured, 'SIGTERM')
  242. const capturedSurvivors = await this.waitForMembers(captured)
  243. const members = this.unionMembers(capturedSurvivors, this.descendants(this.inspector.snapshot()))
  244. this.signalMembers(members, 'SIGKILL')
  245. const survivors = await this.waitForMembers(members)
  246. const observed = this.inspector.snapshot()
  247. return this.survivors(this.unionMembers(survivors, this.descendants(observed)), observed)
  248. }
  249. private async stopShell(): Promise<void> {
  250. if (this.platform === 'win32') {
  251. await this.stopShellWindows()
  252. return
  253. }
  254. if (!this.exited) {
  255. try {
  256. this.terminal.kill('SIGTERM')
  257. } catch (_topLevelAlreadyExitedDuringTerm) {
  258. // The exit callback is authoritative.
  259. }
  260. await Promise.race([this.done.then(() => undefined), delay(this.graceMs)])
  261. }
  262. if (!this.exited) {
  263. try {
  264. this.terminal.kill('SIGKILL')
  265. } catch (_topLevelAlreadyExitedDuringKill) {
  266. // The exit callback is authoritative.
  267. }
  268. await Promise.race([this.done.then(() => undefined), delay(this.graceMs)])
  269. }
  270. if (!this.exited) throw new Error(`terminal cleanup failed; surviving pid: ${this.pid}`)
  271. }
  272. private async stopShellWindows(): Promise<void> {
  273. // node-pty's Windows kill(signal) throws ("Signals not supported on
  274. // windows"), and its bare kill() delegates to a console-list agent that
  275. // fails when the parent has no console. taskkill tree escalation is the
  276. // teardown path, fenced on the shell's start identity like every
  277. // descendant; a root identity miss falls back to the bare kill. taskkill
  278. // termination also does not reliably fire node-pty's exit notification
  279. // (the same console-list agent), so the tiers verify the shell's absence
  280. // through the inspector instead of waiting on `done` alone.
  281. const shellGone = (): boolean =>
  282. this.exited || (this.rootIdentity !== undefined && !this.inspector.isAlive(this.rootIdentity))
  283. if (!shellGone() && this.rootIdentity !== undefined) {
  284. this.inspector.signalProcess(this.rootIdentity, 'SIGTERM')
  285. await this.waitForWindowsShellExit()
  286. }
  287. if (!shellGone() && this.rootIdentity === undefined) {
  288. try {
  289. this.terminal.kill()
  290. } catch (_topLevelAlreadyExitedDuringKill) {
  291. // The exit callback is authoritative.
  292. }
  293. await Promise.race([this.done.then(() => undefined), delay(this.graceMs)])
  294. }
  295. if (!shellGone() && this.rootIdentity !== undefined) {
  296. this.inspector.signalProcess(this.rootIdentity, 'SIGKILL')
  297. await this.waitForWindowsShellExit()
  298. }
  299. if (!shellGone()) throw new Error(`terminal cleanup failed; surviving pid: ${this.pid}`)
  300. }
  301. private async waitForWindowsShellExit(): Promise<void> {
  302. const until = Date.now() + this.graceMs
  303. while (!this.exited && Date.now() < until) {
  304. if (this.rootIdentity !== undefined && !this.inspector.isAlive(this.rootIdentity)) return
  305. await delay(Math.min(25, Math.max(1, until - Date.now())))
  306. }
  307. }
  308. private async closeOnce(): Promise<void> {
  309. if (this.managedOwner !== undefined) {
  310. try {
  311. await this.closeManagedRange(this.managedOwner)
  312. this.dataDisposable.dispose()
  313. this.exitDisposable.dispose()
  314. } finally {
  315. void this.done.finally(() => { this.cleanupManagedOwner(this.managedOwner as BoundProcessOwner) }).catch(() => {})
  316. }
  317. return
  318. }
  319. let survivors = await this.stopDescendants()
  320. if (survivors.length > 0) {
  321. throw new Error(`terminal cleanup failed; surviving pids: ${survivors.map(member => member.pid).join(', ')}`)
  322. }
  323. await this.stopShell()
  324. survivors = await this.stopDescendants()
  325. if (survivors.length > 0) {
  326. throw new Error(`terminal cleanup failed; surviving pids: ${survivors.map(member => member.pid).join(', ')}`)
  327. }
  328. this.settleExitIfGone()
  329. this.dataDisposable.dispose()
  330. this.exitDisposable.dispose()
  331. }
  332. private cleanupManagedOwner(owner: BoundProcessOwner): void {
  333. if (this.managedOwnerCleaned) return
  334. this.managedOwnerCleaned = true
  335. owner.cleanup?.()
  336. }
  337. private async closeManagedRange(owner: BoundProcessOwner): Promise<void> {
  338. owner.signal('SIGTERM')
  339. const observation = owner.waitForExit()
  340. const first = await raceWithDelay(observation.then(
  341. () => ({ kind: 'stopped' as const }),
  342. (error: unknown) => ({ kind: 'failed' as const, error }),
  343. ), this.graceMs, { kind: 'timeout' as const })
  344. if (first.kind !== 'stopped') {
  345. owner.signal('SIGKILL')
  346. if (first.kind === 'failed') {
  347. // The observation failure is still authoritative, but force cleanup
  348. // and a fresh final observation must be attempted before exposing it.
  349. try {
  350. await owner.waitForExit()
  351. } catch (finalError: unknown) {
  352. throw new AggregateError([first.error, finalError], 'terminal managed-range cleanup failed')
  353. }
  354. throw first.error
  355. }
  356. await observation
  357. }
  358. if (!this.exited) {
  359. await raceWithDelay(this.done.then(() => undefined), this.graceMs, undefined)
  360. }
  361. if (!this.exited) throw new Error(`terminal cleanup failed; surviving pid: ${this.pid}`)
  362. }
  363. private settleExitIfGone(): void {
  364. // An externally taskkilled Windows shell may never fire node-pty's exit
  365. // notification (its console-list agent fails without a parent console),
  366. // which would leave `done` — and every consumer awaiting it — unsettled
  367. // forever. Teardown has just verified the shell's absence through the
  368. // inspector, so a missing exit event is itself the outcome.
  369. if (this.platform !== 'win32') return
  370. if (this.exited) return
  371. /* v8 ignore next -- stopShellWindows() verified the shell is gone or threw;
  372. the identity re-check is a defensive fence for a future caller. */
  373. if (this.rootIdentity !== undefined && this.inspector.isAlive(this.rootIdentity)) return
  374. this.exited = true
  375. this.output.end()
  376. this.outcome.resolve({ exitCode: null, signal: null })
  377. }
  378. }