1
0

terminal.ts 13 KB

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