terminal.spec.ts 19 KB

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