terminal.spec.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  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. // Mirrors the real inspectors' alive-gated signalling.
  67. if (!this.alive.has(identity.pid)) return
  68. if (this.throwProcess) throw new Error('process raced')
  69. if (!this.isAlive(identity)) return
  70. this.processes.push([identity.pid, signal])
  71. if (this.removeOnSignal) this.alive.delete(identity.pid)
  72. }
  73. }
  74. afterEach(() => { vi.useRealTimers() })
  75. function makeHandle(pty: FakePty, inspector: ProcessInspector, graceMs: number): LocalTerminalHandle {
  76. // The suite pins POSIX signalling semantics deterministically on every host;
  77. // the win32 branches get their own platform-explicit tests below.
  78. return new LocalTerminalHandle(pty.asPty(), inspector, graceMs, 'linux')
  79. }
  80. describe('LocalTerminalHandle', () => {
  81. it('force-kills descendants around the shell during synchronous host exit', () => {
  82. const pty = new FakePty()
  83. const inspector = new FakeInspector()
  84. const first = { pid: 124, started: 'first' }
  85. const late = { pid: 125, started: 'late' }
  86. inspector.members = [first]
  87. inspector.alive.add(pty.pid)
  88. inspector.alive.add(first.pid)
  89. const signalProcess = inspector.signalProcess.bind(inspector)
  90. inspector.signalProcess = (identity, signal) => {
  91. signalProcess(identity, signal)
  92. if (identity.pid === pty.pid) {
  93. inspector.members = [first, late]
  94. inspector.alive.add(late.pid)
  95. }
  96. }
  97. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
  98. handle.terminateForHostExit()
  99. expect(inspector.processes).toEqual([
  100. [first.pid, 'SIGKILL'],
  101. [pty.pid, 'SIGKILL'],
  102. [late.pid, 'SIGKILL'],
  103. ])
  104. expect(pty.kills).toEqual([])
  105. pty.emitExit()
  106. handle.terminateForHostExit()
  107. expect(pty.kills).toEqual([])
  108. })
  109. it('uses captured identities and contains shell races when final inspection fails', async () => {
  110. const pty = new FakePty()
  111. const inspector = new FakeInspector()
  112. const captured = { pid: 124, started: 'captured' }
  113. inspector.members = [captured]
  114. inspector.alive.add(pty.pid)
  115. inspector.alive.add(captured.pid)
  116. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
  117. await handle.inspectForeground()
  118. inspector.processTree = () => { throw new Error('process table unavailable') }
  119. inspector.throwProcess = true
  120. expect(() => { handle.terminateForHostExit() }).not.toThrow()
  121. expect(inspector.processes).toEqual([])
  122. expect(pty.kills).toEqual([])
  123. })
  124. it('uses node-pty only when the shell start identity was unavailable', () => {
  125. const pty = new FakePty()
  126. const inspector = new FakeInspector()
  127. inspector.root = undefined
  128. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
  129. handle.terminateForHostExit()
  130. expect(pty.kills).toEqual(['SIGKILL'])
  131. const racingPty = new FakePty()
  132. const racingInspector = new FakeInspector()
  133. racingInspector.root = undefined
  134. racingPty.throwKill = true
  135. const racingHandle = new LocalTerminalHandle(racingPty.asPty(), racingInspector, 10)
  136. expect(() => { racingHandle.terminateForHostExit() }).not.toThrow()
  137. })
  138. it('does not signal a recycled terminal root before its delayed exit callback', () => {
  139. const pty = new FakePty()
  140. const inspector = new FakeInspector()
  141. inspector.alive.add(pty.pid)
  142. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
  143. inspector.root = { pid: pty.pid, started: 'recycled' }
  144. inspector.isAlive = identity => identity.started === 'recycled'
  145. handle.terminateForHostExit()
  146. expect(inspector.processes).toEqual([])
  147. expect(pty.kills).toEqual([])
  148. })
  149. it('bridges terminal bytes, foreground control, and signalled exit facts', async () => {
  150. const pty = new FakePty()
  151. const inspector = new FakeInspector()
  152. inspector.waiting = true
  153. const handle = makeHandle(pty, inspector, 10)
  154. const chunks: Buffer[] = []
  155. handle.output.on('data', (chunk: Buffer) => { chunks.push(chunk) })
  156. pty.emitData('hello €')
  157. await handle.write('input\r')
  158. expect(pty.writes).toEqual(['input\r'])
  159. expect(await handle.inspectForeground()).toEqual({ processGroupId: 456, inputWaiting: true })
  160. expect(await handle.signalForeground('SIGINT')).toBe(456)
  161. expect(inspector.groups).toEqual([[456, 'SIGINT']])
  162. pty.emitExit(7, 9)
  163. pty.emitExit(0)
  164. expect(await handle.done).toEqual({ exitCode: null, signal: 'SIGKILL' })
  165. await handle.terminate()
  166. expect(Buffer.concat(chunks).toString('utf8')).toBe('hello €')
  167. })
  168. it('rejects unsafe foreground signals and writes after exit', async () => {
  169. const pty = new FakePty()
  170. const inspector = new FakeInspector()
  171. const handle = makeHandle(pty, inspector, 10)
  172. inspector.pgid = handle.pid
  173. await expect(handle.signalForeground('SIGKILL')).rejects.toThrow('terminate the terminal session')
  174. inspector.pgid = undefined
  175. expect(await handle.inspectForeground()).toBeUndefined()
  176. await expect(handle.signalForeground('SIGTERM')).rejects.toThrow('cannot resolve')
  177. pty.emitExit(3)
  178. expect(await handle.done).toEqual({ exitCode: 3, signal: null })
  179. await handle.terminate()
  180. await expect(handle.write('late')).rejects.toThrow('has exited')
  181. })
  182. it('keeps the shell alive until forced descendants leave', async () => {
  183. vi.useFakeTimers()
  184. const pty = new FakePty()
  185. const inspector = new FakeInspector()
  186. inspector.members = [{ pid: 124, started: 'child' }]
  187. inspector.alive.add(124)
  188. inspector.removeOnSignal = false
  189. const handle = makeHandle(pty, inspector, 20)
  190. const quiescent = handle.terminate()
  191. expect(handle.terminate()).toBe(quiescent)
  192. await vi.advanceTimersByTimeAsync(20)
  193. expect(inspector.processes).toContainEqual([124, 'SIGKILL'])
  194. expect(pty.kills).toEqual([])
  195. inspector.alive.delete(124)
  196. await vi.advanceTimersByTimeAsync(20)
  197. await quiescent
  198. expect(pty.kills).toEqual(['SIGTERM'])
  199. })
  200. it('keeps an early exit wait pending through descendant cleanup', async () => {
  201. vi.useFakeTimers()
  202. const pty = new FakePty()
  203. const inspector = new FakeInspector()
  204. inspector.members = [{ pid: 124, started: 'child' }]
  205. inspector.alive.add(124)
  206. inspector.removeOnSignal = false
  207. const handle = makeHandle(pty, inspector, 20)
  208. pty.emitExit()
  209. const waiting = handle.terminate()
  210. let settled = false
  211. void waiting.then(() => { settled = true })
  212. await vi.advanceTimersByTimeAsync(10)
  213. expect(settled).toBe(false)
  214. inspector.alive.delete(124)
  215. await vi.advanceTimersByTimeAsync(20)
  216. await waiting
  217. })
  218. it('cleans a same-session descendant after the top-level shell exits naturally', async () => {
  219. const pty = new FakePty()
  220. const inspector = new FakeInspector()
  221. const disowned = { pid: 124, started: 'disowned' }
  222. inspector.processSession = () => inspector.alive.has(disowned.pid) ? [disowned] : []
  223. inspector.alive.add(124)
  224. const handle = makeHandle(pty, inspector, 20)
  225. pty.emitExit()
  226. await handle.terminate()
  227. expect(inspector.processes).toEqual([[124, 'SIGTERM']])
  228. })
  229. it('retains an inspected descendant after it reparents away from the shell', async () => {
  230. const pty = new FakePty()
  231. const inspector = new FakeInspector()
  232. const descendant = { pid: 124, started: 'observed' }
  233. inspector.members = [descendant]
  234. inspector.alive.add(descendant.pid)
  235. const handle = makeHandle(pty, inspector, 20)
  236. await handle.inspectForeground()
  237. inspector.members = []
  238. pty.emitExit()
  239. await handle.terminate()
  240. expect(inspector.processes).toEqual([[124, 'SIGTERM']])
  241. })
  242. it('does not adopt the children of a recycled shell pid', async () => {
  243. const pty = new FakePty()
  244. const inspector = new FakeInspector()
  245. const handle = makeHandle(pty, inspector, 10)
  246. pty.emitExit()
  247. const imposterChild = { pid: 999, started: 'imposter-child' }
  248. inspector.root = { pid: 123, started: 'imposter' }
  249. inspector.members = [imposterChild]
  250. inspector.alive.add(imposterChild.pid)
  251. await handle.terminate()
  252. expect(inspector.processes).toEqual([])
  253. })
  254. it('adopts nothing when the shell identity was never observable', async () => {
  255. const pty = new FakePty()
  256. const inspector = new FakeInspector()
  257. inspector.root = undefined
  258. const orphan = { pid: 321, started: 'unverifiable' }
  259. inspector.members = [orphan]
  260. inspector.alive.add(orphan.pid)
  261. const handle = makeHandle(pty, inspector, 10)
  262. await handle.terminate()
  263. expect(inspector.processes).toEqual([])
  264. expect(pty.kills).toEqual(['SIGTERM'])
  265. })
  266. it('rescans for descendants forked during TERM', async () => {
  267. const pty = new FakePty()
  268. const inspector = new FakeInspector()
  269. const root = { pid: 123, started: 'shell' }
  270. let reads = 0
  271. inspector.processTree = () => {
  272. reads += 1
  273. if (reads === 1) return [root]
  274. if (reads === 2) {
  275. inspector.alive.add(124)
  276. return [root, { pid: 124, started: 'first' }]
  277. }
  278. if (reads === 3) {
  279. inspector.alive.add(125)
  280. return [root, { pid: 125, started: 'late' }]
  281. }
  282. return []
  283. }
  284. const handle = makeHandle(pty, inspector, 10)
  285. await handle.terminate()
  286. expect(inspector.processes).toEqual([[124, 'SIGTERM'], [125, 'SIGKILL']])
  287. expect(pty.kills).toEqual(['SIGTERM'])
  288. })
  289. it('sweeps a same-session descendant forked while the shell handles TERM', async () => {
  290. const pty = new FakePty()
  291. const inspector = new FakeInspector()
  292. const late = { pid: 124, started: 'shell-term-trap' }
  293. pty.onKill = () => {
  294. inspector.sessionMembers = [late]
  295. inspector.alive.add(late.pid)
  296. }
  297. const handle = makeHandle(pty, inspector, 10)
  298. await handle.terminate()
  299. expect(inspector.processes).toEqual([[late.pid, 'SIGTERM']])
  300. expect(pty.kills).toEqual(['SIGTERM'])
  301. })
  302. it('retries failed cleanup after a surviving descendant leaves', async () => {
  303. vi.useFakeTimers()
  304. const pty = new FakePty()
  305. const inspector = new FakeInspector()
  306. const late = { pid: 124, started: 'shell-term-survivor' }
  307. inspector.removeOnSignal = false
  308. pty.onKill = () => {
  309. inspector.sessionMembers = [late]
  310. inspector.alive.add(late.pid)
  311. }
  312. const handle = makeHandle(pty, inspector, 10)
  313. const first = handle.terminate()
  314. const failed = expect(first).rejects.toThrow('surviving pids: 124')
  315. await vi.advanceTimersByTimeAsync(25)
  316. await failed
  317. inspector.alive.delete(late.pid)
  318. const retry = handle.terminate()
  319. expect(retry).not.toBe(first)
  320. await retry
  321. expect(inspector.processes).toEqual([[late.pid, 'SIGTERM'], [late.pid, 'SIGKILL']])
  322. })
  323. it('retains captured descendants after reparenting', async () => {
  324. vi.useFakeTimers()
  325. const pty = new FakePty()
  326. const inspector = new FakeInspector()
  327. const captured = { pid: 124, started: 'captured' }
  328. const root = { pid: 123, started: 'shell' }
  329. let reads = 0
  330. inspector.alive.add(captured.pid)
  331. inspector.processTree = () => { reads += 1; return reads === 1 ? [root] : reads === 2 ? [root, captured] : [] }
  332. inspector.signalProcess = (identity, signal) => {
  333. inspector.processes.push([identity.pid, signal])
  334. if (signal === 'SIGKILL') inspector.alive.delete(identity.pid)
  335. }
  336. const handle = makeHandle(pty, inspector, 20)
  337. const quiescent = handle.terminate()
  338. await vi.advanceTimersByTimeAsync(25)
  339. await quiescent
  340. expect(inspector.processes).toEqual([[124, 'SIGTERM'], [124, 'SIGKILL']])
  341. })
  342. it('reports a top-level process that ignores escalation', async () => {
  343. vi.useFakeTimers()
  344. const pty = new FakePty()
  345. pty.autoExitOnKill = false
  346. const handle = makeHandle(pty, new FakeInspector(), 10)
  347. const failed = expect(handle.terminate()).rejects.toThrow('surviving pid: 123')
  348. await vi.advanceTimersByTimeAsync(25)
  349. await failed
  350. expect(pty.kills).toEqual(['SIGTERM', 'SIGKILL'])
  351. pty.emitExit(0, 999)
  352. expect(await handle.done).toEqual({ exitCode: null, signal: null })
  353. await handle.terminate()
  354. })
  355. it('contains process races while reporting surviving descendants', async () => {
  356. const pty = new FakePty()
  357. pty.throwKill = true
  358. const inspector = new FakeInspector()
  359. inspector.members = [{ pid: 124, started: 'child' }]
  360. inspector.alive.add(124)
  361. inspector.throwProcess = true
  362. const handle = makeHandle(pty, inspector, 1)
  363. await expect(handle.terminate()).rejects.toThrow('surviving pids: 124')
  364. })
  365. })
  366. describe('LocalTerminalHandle on Windows', () => {
  367. const win32 = 'win32' as NodeJS.Platform
  368. it('delivers SIGINT as a Ctrl-C input write without inspector signalling', async () => {
  369. const pty = new FakePty()
  370. const inspector = new FakeInspector()
  371. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32)
  372. await expect(handle.signalForeground('SIGINT')).resolves.toBe(456)
  373. expect(pty.writes).toEqual(['\x03'])
  374. expect(inspector.groups).toEqual([])
  375. })
  376. it('rejects SIGTSTP and SIGHUP as unavailable on Windows', async () => {
  377. const handle = new LocalTerminalHandle(new FakePty().asPty(), new FakeInspector(), 10, win32)
  378. await expect(handle.signalForeground('SIGTSTP')).rejects.toThrow('unsupported on Windows')
  379. await expect(handle.signalForeground('SIGHUP')).rejects.toThrow('unsupported on Windows')
  380. })
  381. it('routes SIGTERM through the inspector tree with the pseudo foreground group', async () => {
  382. const pty = new FakePty()
  383. const inspector = new FakeInspector()
  384. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32)
  385. await expect(handle.signalForeground('SIGTERM')).resolves.toBe(456)
  386. expect(inspector.groups).toEqual([[456, 'SIGTERM']])
  387. expect(pty.writes).toEqual([])
  388. })
  389. it('still refuses to SIGKILL the terminal shell on Windows', async () => {
  390. const pty = new FakePty()
  391. const inspector = new FakeInspector()
  392. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32)
  393. inspector.pgid = handle.pid
  394. await expect(handle.signalForeground('SIGKILL')).rejects.toThrow('terminate the terminal session')
  395. })
  396. it('escalates the shell through taskkill tiers instead of node-pty signal kills', async () => {
  397. vi.useFakeTimers()
  398. const pty = new FakePty()
  399. const inspector = new FakeInspector()
  400. inspector.alive.add(123)
  401. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32)
  402. const quiescent = handle.terminate()
  403. await vi.advanceTimersByTimeAsync(5)
  404. expect(inspector.processes).toEqual([[123, 'SIGTERM']])
  405. expect(pty.kills).toEqual([])
  406. pty.emitExit()
  407. await quiescent
  408. expect(inspector.processes).toEqual([[123, 'SIGTERM']])
  409. expect(pty.kills).toEqual([])
  410. })
  411. it('reports a shell that survives both taskkill tiers', async () => {
  412. vi.useFakeTimers()
  413. const pty = new FakePty()
  414. const inspector = new FakeInspector()
  415. inspector.alive.add(123)
  416. inspector.removeOnSignal = false
  417. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32)
  418. const failed = expect(handle.terminate()).rejects.toThrow('surviving pid: 123')
  419. await vi.advanceTimersByTimeAsync(25)
  420. await failed
  421. expect(inspector.processes).toEqual([[123, 'SIGTERM'], [123, 'SIGKILL']])
  422. expect(pty.kills).toEqual([])
  423. pty.emitExit()
  424. await handle.terminate()
  425. })
  426. it('skips taskkill escalation entirely when the shell already exited', async () => {
  427. const pty = new FakePty()
  428. const inspector = new FakeInspector()
  429. inspector.alive.add(123)
  430. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32)
  431. pty.emitExit()
  432. await handle.terminate()
  433. expect(inspector.processes).toEqual([])
  434. expect(pty.kills).toEqual([])
  435. })
  436. it('falls back to the bare node-pty kill when the shell identity was never observable', async () => {
  437. const pty = new FakePty()
  438. const inspector = new FakeInspector()
  439. inspector.root = undefined
  440. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32)
  441. await handle.terminate()
  442. expect(pty.kills).toHaveLength(1)
  443. expect(inspector.processes).toEqual([])
  444. })
  445. })