terminal.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. import { afterEach, describe, expect, it, vi } from 'vitest'
  2. import type { IDisposable, IPty } from 'node-pty'
  3. import { LocalTerminalHandle } from '@deepseek-ai/dsh-subprocess-local/src/terminal.ts'
  4. import type {
  5. ProcessIdentity,
  6. ProcessInspector,
  7. } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts'
  8. import type { SubprocessTerminalSignal } from '@deepseek-ai/dsh-subprocess'
  9. class FakePty {
  10. pid = 123
  11. readonly writes: string[] = []
  12. readonly kills: string[] = []
  13. autoExitOnKill = true
  14. throwKill = false
  15. onKill?: () => void
  16. private readonly dataListeners = new Set<(data: string) => void>()
  17. private readonly exitListeners = new Set<(event: { exitCode: number; signal?: number }) => void>()
  18. readonly onData = (listener: (data: string) => void): IDisposable => {
  19. this.dataListeners.add(listener)
  20. return { dispose: () => { this.dataListeners.delete(listener) } }
  21. }
  22. readonly onExit = (listener: (event: { exitCode: number; signal?: number }) => void): IDisposable => {
  23. this.exitListeners.add(listener)
  24. return { dispose: () => { this.exitListeners.delete(listener) } }
  25. }
  26. emitData(data: string): void {
  27. for (const listener of this.dataListeners) listener(data)
  28. }
  29. emitExit(exitCode = 0, signal?: number): void {
  30. for (const listener of this.exitListeners) listener({ exitCode, ...signal === undefined ? {} : { signal } })
  31. }
  32. write(data: string): void { this.writes.push(data) }
  33. kill(signal?: string): void {
  34. if (this.throwKill) throw new Error('process raced')
  35. this.kills.push(signal ?? 'SIGHUP')
  36. this.onKill?.()
  37. if (this.autoExitOnKill) this.emitExit(0, signal === 'SIGKILL' ? 9 : 15)
  38. }
  39. asPty(): IPty {
  40. return this as unknown as IPty
  41. }
  42. }
  43. class FakeInspector implements ProcessInspector {
  44. pgid: number | undefined = 456
  45. waiting = false
  46. /** The shell's own row, present like the real /proc- and ps-backed scans; tests recycle or drop it. */
  47. root: ProcessIdentity | undefined = { pid: 123, started: 'shell' }
  48. members: ProcessIdentity[] = []
  49. sessionMembers: ProcessIdentity[] = []
  50. readonly alive = new Set<number>()
  51. readonly groups: Array<[number, SubprocessTerminalSignal]> = []
  52. readonly processes: Array<[number, 'SIGTERM' | 'SIGKILL']> = []
  53. throwGroup = false
  54. throwProcess = false
  55. removeOnSignal = true
  56. foregroundPgid() { return this.pgid }
  57. isStdinWaiting() { return this.waiting }
  58. processTree() { return this.root === undefined ? this.members : [this.root, ...this.members] }
  59. processSession() { return this.sessionMembers }
  60. isAlive(identity: ProcessIdentity) { return this.alive.has(identity.pid) }
  61. signalGroup(pgid: number, signal: SubprocessTerminalSignal) {
  62. if (this.throwGroup) throw new Error('group failed')
  63. this.groups.push([pgid, signal])
  64. }
  65. signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL') {
  66. if (this.throwProcess) throw new Error('process raced')
  67. this.processes.push([identity.pid, signal])
  68. if (this.removeOnSignal) this.alive.delete(identity.pid)
  69. }
  70. }
  71. afterEach(() => { vi.useRealTimers() })
  72. describe('LocalTerminalHandle', () => {
  73. it('bridges terminal bytes, foreground control, and signalled exit facts', async () => {
  74. const pty = new FakePty()
  75. const inspector = new FakeInspector()
  76. inspector.waiting = true
  77. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
  78. const chunks: Buffer[] = []
  79. handle.output.on('data', (chunk: Buffer) => { chunks.push(chunk) })
  80. pty.emitData('hello €')
  81. await handle.write('input\r')
  82. expect(pty.writes).toEqual(['input\r'])
  83. expect(await handle.inspectForeground()).toEqual({ processGroupId: 456, inputWaiting: true })
  84. expect(await handle.signalForeground('SIGINT')).toBe(456)
  85. expect(inspector.groups).toEqual([[456, 'SIGINT']])
  86. pty.emitExit(7, 9)
  87. pty.emitExit(0)
  88. expect(await handle.done).toEqual({ exitCode: null, signal: 'SIGKILL' })
  89. await handle.terminate()
  90. expect(Buffer.concat(chunks).toString('utf8')).toBe('hello €')
  91. })
  92. it('rejects unsafe foreground signals and writes after exit', async () => {
  93. const pty = new FakePty()
  94. const inspector = new FakeInspector()
  95. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
  96. inspector.pgid = handle.pid
  97. await expect(handle.signalForeground('SIGKILL')).rejects.toThrow('terminate the terminal session')
  98. inspector.pgid = undefined
  99. expect(await handle.inspectForeground()).toBeUndefined()
  100. await expect(handle.signalForeground('SIGTERM')).rejects.toThrow('cannot resolve')
  101. pty.emitExit(3)
  102. expect(await handle.done).toEqual({ exitCode: 3, signal: null })
  103. await handle.terminate()
  104. await expect(handle.write('late')).rejects.toThrow('has exited')
  105. })
  106. it('keeps the shell alive until forced descendants leave', async () => {
  107. vi.useFakeTimers()
  108. const pty = new FakePty()
  109. const inspector = new FakeInspector()
  110. inspector.members = [{ pid: 124, started: 'child' }]
  111. inspector.alive.add(124)
  112. inspector.removeOnSignal = false
  113. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20)
  114. const quiescent = handle.terminate()
  115. expect(handle.terminate()).toBe(quiescent)
  116. await vi.advanceTimersByTimeAsync(20)
  117. expect(inspector.processes).toContainEqual([124, 'SIGKILL'])
  118. expect(pty.kills).toEqual([])
  119. inspector.alive.delete(124)
  120. await vi.advanceTimersByTimeAsync(20)
  121. await quiescent
  122. expect(pty.kills).toEqual(['SIGTERM'])
  123. })
  124. it('keeps an early exit wait pending through descendant cleanup', async () => {
  125. vi.useFakeTimers()
  126. const pty = new FakePty()
  127. const inspector = new FakeInspector()
  128. inspector.members = [{ pid: 124, started: 'child' }]
  129. inspector.alive.add(124)
  130. inspector.removeOnSignal = false
  131. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20)
  132. pty.emitExit()
  133. const waiting = handle.terminate()
  134. let settled = false
  135. void waiting.then(() => { settled = true })
  136. await vi.advanceTimersByTimeAsync(10)
  137. expect(settled).toBe(false)
  138. inspector.alive.delete(124)
  139. await vi.advanceTimersByTimeAsync(20)
  140. await waiting
  141. })
  142. it('cleans a same-session descendant after the top-level shell exits naturally', async () => {
  143. const pty = new FakePty()
  144. const inspector = new FakeInspector()
  145. const disowned = { pid: 124, started: 'disowned' }
  146. inspector.processSession = () => inspector.alive.has(disowned.pid) ? [disowned] : []
  147. inspector.alive.add(124)
  148. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20)
  149. pty.emitExit()
  150. await handle.terminate()
  151. expect(inspector.processes).toEqual([[124, 'SIGTERM']])
  152. })
  153. it('retains an inspected descendant after it reparents away from the shell', async () => {
  154. const pty = new FakePty()
  155. const inspector = new FakeInspector()
  156. const descendant = { pid: 124, started: 'observed' }
  157. inspector.members = [descendant]
  158. inspector.alive.add(descendant.pid)
  159. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20)
  160. await handle.inspectForeground()
  161. inspector.members = []
  162. pty.emitExit()
  163. await handle.terminate()
  164. expect(inspector.processes).toEqual([[124, 'SIGTERM']])
  165. })
  166. it('does not adopt the children of a recycled shell pid', async () => {
  167. const pty = new FakePty()
  168. const inspector = new FakeInspector()
  169. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
  170. pty.emitExit()
  171. const imposterChild = { pid: 999, started: 'imposter-child' }
  172. inspector.root = { pid: 123, started: 'imposter' }
  173. inspector.members = [imposterChild]
  174. inspector.alive.add(imposterChild.pid)
  175. await handle.terminate()
  176. expect(inspector.processes).toEqual([])
  177. })
  178. it('adopts nothing when the shell identity was never observable', async () => {
  179. const pty = new FakePty()
  180. const inspector = new FakeInspector()
  181. inspector.root = undefined
  182. const orphan = { pid: 321, started: 'unverifiable' }
  183. inspector.members = [orphan]
  184. inspector.alive.add(orphan.pid)
  185. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
  186. await handle.terminate()
  187. expect(inspector.processes).toEqual([])
  188. expect(pty.kills).toEqual(['SIGTERM'])
  189. })
  190. it('rescans for descendants forked during TERM', async () => {
  191. const pty = new FakePty()
  192. const inspector = new FakeInspector()
  193. const root = { pid: 123, started: 'shell' }
  194. let reads = 0
  195. inspector.processTree = () => {
  196. reads += 1
  197. if (reads === 1) return [root]
  198. if (reads === 2) {
  199. inspector.alive.add(124)
  200. return [root, { pid: 124, started: 'first' }]
  201. }
  202. if (reads === 3) {
  203. inspector.alive.add(125)
  204. return [root, { pid: 125, started: 'late' }]
  205. }
  206. return []
  207. }
  208. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
  209. await handle.terminate()
  210. expect(inspector.processes).toEqual([[124, 'SIGTERM'], [125, 'SIGKILL']])
  211. expect(pty.kills).toEqual(['SIGTERM'])
  212. })
  213. it('sweeps a same-session descendant forked while the shell handles TERM', async () => {
  214. const pty = new FakePty()
  215. const inspector = new FakeInspector()
  216. const late = { pid: 124, started: 'shell-term-trap' }
  217. pty.onKill = () => {
  218. inspector.sessionMembers = [late]
  219. inspector.alive.add(late.pid)
  220. }
  221. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
  222. await handle.terminate()
  223. expect(inspector.processes).toEqual([[late.pid, 'SIGTERM']])
  224. expect(pty.kills).toEqual(['SIGTERM'])
  225. })
  226. it('retries failed cleanup after a surviving descendant leaves', async () => {
  227. vi.useFakeTimers()
  228. const pty = new FakePty()
  229. const inspector = new FakeInspector()
  230. const late = { pid: 124, started: 'shell-term-survivor' }
  231. inspector.removeOnSignal = false
  232. pty.onKill = () => {
  233. inspector.sessionMembers = [late]
  234. inspector.alive.add(late.pid)
  235. }
  236. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
  237. const first = handle.terminate()
  238. const failed = expect(first).rejects.toThrow('surviving pids: 124')
  239. await vi.advanceTimersByTimeAsync(25)
  240. await failed
  241. inspector.alive.delete(late.pid)
  242. const retry = handle.terminate()
  243. expect(retry).not.toBe(first)
  244. await retry
  245. expect(inspector.processes).toEqual([[late.pid, 'SIGTERM'], [late.pid, 'SIGKILL']])
  246. })
  247. it('retains captured descendants after reparenting', async () => {
  248. vi.useFakeTimers()
  249. const pty = new FakePty()
  250. const inspector = new FakeInspector()
  251. const captured = { pid: 124, started: 'captured' }
  252. const root = { pid: 123, started: 'shell' }
  253. let reads = 0
  254. inspector.alive.add(captured.pid)
  255. inspector.processTree = () => { reads += 1; return reads === 1 ? [root] : reads === 2 ? [root, captured] : [] }
  256. inspector.signalProcess = (identity, signal) => {
  257. inspector.processes.push([identity.pid, signal])
  258. if (signal === 'SIGKILL') inspector.alive.delete(identity.pid)
  259. }
  260. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20)
  261. const quiescent = handle.terminate()
  262. await vi.advanceTimersByTimeAsync(25)
  263. await quiescent
  264. expect(inspector.processes).toEqual([[124, 'SIGTERM'], [124, 'SIGKILL']])
  265. })
  266. it('reports a top-level process that ignores escalation', async () => {
  267. vi.useFakeTimers()
  268. const pty = new FakePty()
  269. pty.autoExitOnKill = false
  270. const handle = new LocalTerminalHandle(pty.asPty(), new FakeInspector(), 10)
  271. const failed = expect(handle.terminate()).rejects.toThrow('surviving pid: 123')
  272. await vi.advanceTimersByTimeAsync(25)
  273. await failed
  274. expect(pty.kills).toEqual(['SIGTERM', 'SIGKILL'])
  275. pty.emitExit(0, 999)
  276. expect(await handle.done).toEqual({ exitCode: null, signal: null })
  277. await handle.terminate()
  278. })
  279. it('contains process races while reporting surviving descendants', async () => {
  280. const pty = new FakePty()
  281. pty.throwKill = true
  282. const inspector = new FakeInspector()
  283. inspector.members = [{ pid: 124, started: 'child' }]
  284. inspector.alive.add(124)
  285. inspector.throwProcess = true
  286. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 1)
  287. await expect(handle.terminate()).rejects.toThrow('surviving pids: 124')
  288. })
  289. })