terminal.spec.ts 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835
  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 { createProcessInspector } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts'
  5. import type {
  6. ProcessIdentity,
  7. ProcessInspector,
  8. ProcessInspectorInternals,
  9. ProcessSnapshot,
  10. } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts'
  11. import type { BoundProcessOwner } from '@deepseek-ai/dsh-subprocess-local/src/managed-owner.ts'
  12. import type { SubprocessTerminalSignal } from '@deepseek-ai/dsh-subprocess'
  13. class FakePty {
  14. pid = 123
  15. readonly writes: string[] = []
  16. readonly kills: string[] = []
  17. autoExitOnKill = true
  18. throwKill = false
  19. onKill?: () => void
  20. private readonly dataListeners = new Set<(data: string) => void>()
  21. private readonly exitListeners = new Set<(event: { exitCode: number; signal?: number }) => void>()
  22. readonly onData = (listener: (data: string) => void): IDisposable => {
  23. this.dataListeners.add(listener)
  24. return { dispose: () => { this.dataListeners.delete(listener) } }
  25. }
  26. readonly onExit = (listener: (event: { exitCode: number; signal?: number }) => void): IDisposable => {
  27. this.exitListeners.add(listener)
  28. return { dispose: () => { this.exitListeners.delete(listener) } }
  29. }
  30. emitData(data: string): void {
  31. for (const listener of this.dataListeners) listener(data)
  32. }
  33. emitExit(exitCode = 0, signal?: number): void {
  34. for (const listener of this.exitListeners) listener({ exitCode, ...signal === undefined ? {} : { signal } })
  35. }
  36. write(data: string): void { this.writes.push(data) }
  37. kill(signal?: string): void {
  38. if (this.throwKill) throw new Error('process raced')
  39. this.kills.push(signal ?? 'SIGHUP')
  40. this.onKill?.()
  41. if (this.autoExitOnKill) this.emitExit(0, signal === 'SIGKILL' ? 9 : 15)
  42. }
  43. asPty(): IPty {
  44. return this as unknown as IPty
  45. }
  46. }
  47. class FakeInspector implements ProcessInspector {
  48. pgid: number | undefined = 456
  49. waiting = false
  50. /** The shell's own row, present like the real /proc- and ps-backed scans; tests recycle or drop it. */
  51. root: ProcessIdentity | undefined = { pid: 123, started: 'shell' }
  52. members: ProcessIdentity[] = []
  53. sessionMembers: ProcessIdentity[] = []
  54. readonly alive = new Set<number>()
  55. readonly groups: Array<[number, SubprocessTerminalSignal]> = []
  56. readonly processes: Array<[number, 'SIGTERM' | 'SIGKILL']> = []
  57. readonly stdinChecks: Array<[number, number]> = []
  58. throwGroup = false
  59. throwProcess = false
  60. removeOnSignal = true
  61. foregroundPgid() { return this.pgid }
  62. isStdinWaiting(pgid: number, shellPid: number) {
  63. this.stdinChecks.push([pgid, shellPid])
  64. return this.waiting
  65. }
  66. /** Per-question table reads; tests replace one to stage a scan without rebuilding the fake. */
  67. readTree: () => ProcessIdentity[] = () => this.root === undefined ? this.members : [this.root, ...this.members]
  68. readSession: () => ProcessIdentity[] = () => this.sessionMembers
  69. readAlive: (identity: ProcessIdentity) => boolean = identity => this.alive.has(identity.pid)
  70. /** Liveness as of right now; tests diverge it from readAlive to stage an exit between scan and signal. */
  71. readCurrentAlive: (identity: ProcessIdentity) => boolean = identity => this.readAlive(identity)
  72. /** Counts process-table captures so read-amplification cases can pin them. */
  73. captures = 0
  74. snapshot(): ProcessSnapshot {
  75. this.captures += 1
  76. return {
  77. tree: () => this.readTree(),
  78. session: () => this.readSession(),
  79. alive: identity => this.readAlive(identity),
  80. }
  81. }
  82. isAlive(identity: ProcessIdentity) { return this.readCurrentAlive(identity) }
  83. signalGroup(pgid: number, signal: SubprocessTerminalSignal) {
  84. if (this.throwGroup) throw new Error('group failed')
  85. this.groups.push([pgid, signal])
  86. }
  87. signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL') {
  88. // Mirrors the real inspectors' alive-gated signalling.
  89. if (this.throwProcess) throw new Error('process raced')
  90. if (!this.isAlive(identity)) return
  91. this.processes.push([identity.pid, signal])
  92. if (this.removeOnSignal) this.alive.delete(identity.pid)
  93. }
  94. }
  95. afterEach(() => { vi.useRealTimers() })
  96. function makeHandle(pty: FakePty, inspector: ProcessInspector, graceMs: number): LocalTerminalHandle {
  97. // The suite pins POSIX signalling semantics deterministically on every host;
  98. // the win32 branches get their own platform-explicit tests below.
  99. return new LocalTerminalHandle(pty.asPty(), inspector, graceMs, 'linux')
  100. }
  101. describe('LocalTerminalHandle', () => {
  102. it('terminates a managed range with TERM when it stops within the grace period', async () => {
  103. vi.useFakeTimers()
  104. const pty = new FakePty()
  105. const inspector = new FakeInspector()
  106. const stopped = Promise.withResolvers<undefined>()
  107. const signals: Array<'SIGTERM' | 'SIGKILL'> = []
  108. const owner: BoundProcessOwner = {
  109. signal(signal) {
  110. signals.push(signal)
  111. if (signal === 'SIGTERM') {
  112. pty.emitExit(0, 15)
  113. stopped.resolve(undefined)
  114. }
  115. },
  116. waitForExit: () => stopped.promise,
  117. terminateForHostExit: vi.fn(),
  118. }
  119. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, 'linux', owner)
  120. await handle.terminate()
  121. expect(signals).toEqual(['SIGTERM'])
  122. await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
  123. expect(vi.getTimerCount()).toBe(0)
  124. })
  125. it('cancels the terminal-exit grace when the pty exits first', async () => {
  126. vi.useFakeTimers()
  127. const pty = new FakePty()
  128. const stopped = Promise.withResolvers<undefined>()
  129. const signals: Array<'SIGTERM' | 'SIGKILL'> = []
  130. const owner: BoundProcessOwner = {
  131. signal(signal) {
  132. signals.push(signal)
  133. if (signal === 'SIGTERM') {
  134. stopped.resolve(undefined)
  135. setTimeout(() => { pty.emitExit(0, 15) }, 1)
  136. }
  137. },
  138. waitForExit: () => stopped.promise,
  139. terminateForHostExit: vi.fn(),
  140. }
  141. const handle = new LocalTerminalHandle(pty.asPty(), new FakeInspector(), 100, 'linux', owner)
  142. const terminating = handle.terminate()
  143. await vi.advanceTimersByTimeAsync(1)
  144. await terminating
  145. expect(signals).toEqual(['SIGTERM'])
  146. expect(vi.getTimerCount()).toBe(0)
  147. })
  148. it('escalates a managed range to KILL after the TERM grace expires', async () => {
  149. vi.useFakeTimers()
  150. const pty = new FakePty()
  151. const inspector = new FakeInspector()
  152. const stopped = Promise.withResolvers<undefined>()
  153. const signals: Array<'SIGTERM' | 'SIGKILL'> = []
  154. const owner: BoundProcessOwner = {
  155. signal(signal) {
  156. signals.push(signal)
  157. if (signal === 'SIGKILL') {
  158. pty.emitExit(0, 9)
  159. stopped.resolve(undefined)
  160. }
  161. },
  162. waitForExit: () => stopped.promise,
  163. terminateForHostExit: vi.fn(),
  164. }
  165. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, 'linux', owner)
  166. const terminating = handle.terminate()
  167. await vi.advanceTimersByTimeAsync(10)
  168. await terminating
  169. expect(signals).toEqual(['SIGTERM', 'SIGKILL'])
  170. })
  171. it('force-kills and retries a managed range when observation first rejects', async () => {
  172. const pty = new FakePty()
  173. const failure = new Error('scope became unreadable')
  174. const signals: Array<'SIGTERM' | 'SIGKILL'> = []
  175. const waitForExit = vi.fn()
  176. .mockRejectedValueOnce(failure)
  177. .mockResolvedValue(undefined)
  178. const owner: BoundProcessOwner = {
  179. signal: (signal) => { signals.push(signal) },
  180. waitForExit,
  181. terminateForHostExit: vi.fn(),
  182. }
  183. const handle = new LocalTerminalHandle(pty.asPty(), new FakeInspector(), 10, 'linux', owner)
  184. await expect(handle.terminate()).rejects.toBe(failure)
  185. expect(signals).toEqual(['SIGTERM', 'SIGKILL'])
  186. expect(waitForExit).toHaveBeenCalledTimes(2)
  187. })
  188. it('preserves both failed managed-range observations after force-kill', async () => {
  189. const pty = new FakePty()
  190. const firstFailure = new Error('scope became unreadable')
  191. const finalFailure = new Error('scope stayed unreadable')
  192. const signals: Array<'SIGTERM' | 'SIGKILL'> = []
  193. const owner: BoundProcessOwner = {
  194. signal: (signal) => { signals.push(signal) },
  195. waitForExit: vi.fn()
  196. .mockRejectedValueOnce(firstFailure)
  197. .mockRejectedValueOnce(finalFailure),
  198. terminateForHostExit: vi.fn(),
  199. }
  200. const handle = new LocalTerminalHandle(pty.asPty(), new FakeInspector(), 10, 'linux', owner)
  201. await expect(handle.terminate()).rejects.toMatchObject({
  202. errors: [firstFailure, finalFailure],
  203. message: 'terminal managed-range cleanup failed',
  204. })
  205. expect(signals).toEqual(['SIGTERM', 'SIGKILL'])
  206. })
  207. it('routes managed terminal host exit directly to KILL', () => {
  208. const pty = new FakePty()
  209. const inspector = new FakeInspector()
  210. const signal = vi.fn()
  211. const terminateForHostExit = vi.fn()
  212. const owner: BoundProcessOwner = { signal, waitForExit: async () => {}, terminateForHostExit }
  213. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, 'linux', owner)
  214. handle.terminateForHostExit()
  215. expect(signal).not.toHaveBeenCalled()
  216. expect(terminateForHostExit).toHaveBeenCalledOnce()
  217. expect(inspector.processes).toEqual([])
  218. expect(pty.kills).toEqual([])
  219. })
  220. it('rejects managed outcome conversion and cleans through its owner exactly once', async () => {
  221. const pty = new FakePty()
  222. const failure = new Error('invalid bootstrap outcome')
  223. const cleanup = vi.fn()
  224. const owner: BoundProcessOwner = {
  225. signal: vi.fn(),
  226. waitForExit: async () => {},
  227. terminateForHostExit: vi.fn(),
  228. cleanup,
  229. }
  230. const handle = new LocalTerminalHandle(
  231. pty.asPty(),
  232. new FakeInspector(),
  233. 10,
  234. 'linux',
  235. owner,
  236. () => { throw failure },
  237. )
  238. pty.emitExit()
  239. await expect(handle.done).rejects.toBe(failure)
  240. await expect(handle.terminate()).resolves.toBeUndefined()
  241. await expect(handle.terminate()).resolves.toBeUndefined()
  242. await vi.waitFor(() => { expect(cleanup).toHaveBeenCalledOnce() })
  243. })
  244. it('runs owner cleanup once after repeated failed managed termination attempts', async () => {
  245. const pty = new FakePty()
  246. const failure = new Error('scope stayed unreadable')
  247. const cleanup = vi.fn()
  248. const owner: BoundProcessOwner = {
  249. signal: vi.fn(),
  250. waitForExit: vi.fn(async () => { throw failure }),
  251. terminateForHostExit: vi.fn(),
  252. cleanup,
  253. }
  254. const handle = new LocalTerminalHandle(pty.asPty(), new FakeInspector(), 10, 'linux', owner)
  255. await expect(handle.terminate()).rejects.toThrow('terminal managed-range cleanup failed')
  256. await expect(handle.terminate()).rejects.toThrow('terminal managed-range cleanup failed')
  257. expect(cleanup).not.toHaveBeenCalled()
  258. pty.emitExit()
  259. await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
  260. await vi.waitFor(() => { expect(cleanup).toHaveBeenCalledOnce() })
  261. })
  262. it('waits for the node-pty exit event after the managed range becomes empty', async () => {
  263. const pty = new FakePty()
  264. const owner: BoundProcessOwner = {
  265. signal: vi.fn(),
  266. waitForExit: async () => {},
  267. terminateForHostExit: vi.fn(),
  268. }
  269. const handle = new LocalTerminalHandle(pty.asPty(), new FakeInspector(), 100, 'linux', owner)
  270. let settled = false
  271. const terminating = handle.terminate().then(() => { settled = true })
  272. await new Promise(resolve => setImmediate(resolve))
  273. expect(settled).toBe(false)
  274. pty.emitExit()
  275. await terminating
  276. })
  277. it('rejects when a managed range stops but node-pty never publishes exit', async () => {
  278. vi.useFakeTimers()
  279. const pty = new FakePty()
  280. const signals: Array<'SIGTERM' | 'SIGKILL'> = []
  281. const owner: BoundProcessOwner = {
  282. signal: (signal) => { signals.push(signal) },
  283. waitForExit: async () => {},
  284. terminateForHostExit: vi.fn(),
  285. }
  286. const handle = new LocalTerminalHandle(pty.asPty(), new FakeInspector(), 10, 'linux', owner)
  287. const terminating = handle.terminate()
  288. const rejected = expect(terminating).rejects.toThrow('terminal cleanup failed; surviving pid: 123')
  289. await vi.advanceTimersByTimeAsync(10)
  290. await rejected
  291. expect(signals).toEqual(['SIGTERM'])
  292. })
  293. it('force-kills descendants around the shell during synchronous host exit', () => {
  294. const pty = new FakePty()
  295. const inspector = new FakeInspector()
  296. const first = { pid: 124, started: 'first' }
  297. const late = { pid: 125, started: 'late' }
  298. inspector.members = [first]
  299. inspector.alive.add(pty.pid)
  300. inspector.alive.add(first.pid)
  301. const signalProcess = inspector.signalProcess.bind(inspector)
  302. inspector.signalProcess = (identity, signal) => {
  303. signalProcess(identity, signal)
  304. if (identity.pid === pty.pid) {
  305. inspector.members = [first, late]
  306. inspector.alive.add(late.pid)
  307. }
  308. }
  309. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
  310. handle.terminateForHostExit()
  311. expect(inspector.processes).toEqual([
  312. [first.pid, 'SIGKILL'],
  313. [pty.pid, 'SIGKILL'],
  314. [late.pid, 'SIGKILL'],
  315. ])
  316. expect(pty.kills).toEqual([])
  317. pty.emitExit()
  318. handle.terminateForHostExit()
  319. expect(pty.kills).toEqual([])
  320. })
  321. it('uses captured identities and contains shell races when final inspection fails', async () => {
  322. const pty = new FakePty()
  323. const inspector = new FakeInspector()
  324. const captured = { pid: 124, started: 'captured' }
  325. inspector.members = [captured]
  326. inspector.alive.add(pty.pid)
  327. inspector.alive.add(captured.pid)
  328. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
  329. await handle.inspectForeground()
  330. inspector.readTree = () => { throw new Error('process table unavailable') }
  331. inspector.throwProcess = true
  332. expect(() => { handle.terminateForHostExit() }).not.toThrow()
  333. expect(inspector.processes).toEqual([])
  334. expect(pty.kills).toEqual([])
  335. })
  336. it('uses node-pty only when the shell start identity was unavailable', () => {
  337. const pty = new FakePty()
  338. const inspector = new FakeInspector()
  339. inspector.root = undefined
  340. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
  341. handle.terminateForHostExit()
  342. expect(pty.kills).toEqual(['SIGKILL'])
  343. const racingPty = new FakePty()
  344. const racingInspector = new FakeInspector()
  345. racingInspector.root = undefined
  346. racingPty.throwKill = true
  347. const racingHandle = new LocalTerminalHandle(racingPty.asPty(), racingInspector, 10)
  348. expect(() => { racingHandle.terminateForHostExit() }).not.toThrow()
  349. })
  350. it('does not signal a recycled terminal root before its delayed exit callback', () => {
  351. const pty = new FakePty()
  352. const inspector = new FakeInspector()
  353. inspector.alive.add(pty.pid)
  354. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
  355. inspector.root = { pid: pty.pid, started: 'recycled' }
  356. inspector.readAlive = identity => identity.started === 'recycled'
  357. handle.terminateForHostExit()
  358. expect(inspector.processes).toEqual([])
  359. expect(pty.kills).toEqual([])
  360. })
  361. it('bridges terminal bytes, foreground control, and signalled exit facts', async () => {
  362. const pty = new FakePty()
  363. const inspector = new FakeInspector()
  364. inspector.waiting = true
  365. const handle = makeHandle(pty, inspector, 10)
  366. const chunks: Buffer[] = []
  367. handle.output.on('data', (chunk: Buffer) => { chunks.push(chunk) })
  368. pty.emitData('hello €')
  369. await handle.write('input\r')
  370. expect(pty.writes).toEqual(['input\r'])
  371. expect(await handle.inspectForeground()).toEqual({ processGroupId: 456, inputWaiting: true })
  372. expect(inspector.stdinChecks).toEqual([[456, 123]])
  373. expect(await handle.signalForeground('SIGINT')).toBe(456)
  374. expect(inspector.groups).toEqual([[456, 'SIGINT']])
  375. pty.emitExit(7, 9)
  376. pty.emitExit(0)
  377. expect(await handle.done).toEqual({ exitCode: null, signal: 'SIGKILL' })
  378. await handle.terminate()
  379. expect(Buffer.concat(chunks).toString('utf8')).toBe('hello €')
  380. })
  381. it('rejects unsafe foreground signals and writes after exit', async () => {
  382. const pty = new FakePty()
  383. const inspector = new FakeInspector()
  384. const handle = makeHandle(pty, inspector, 10)
  385. inspector.pgid = handle.pid
  386. await expect(handle.signalForeground('SIGKILL')).rejects.toThrow('terminate the terminal session')
  387. inspector.pgid = undefined
  388. expect(await handle.inspectForeground()).toBeUndefined()
  389. await expect(handle.signalForeground('SIGTERM')).rejects.toThrow('cannot resolve')
  390. pty.emitExit(3)
  391. expect(await handle.done).toEqual({ exitCode: 3, signal: null })
  392. await handle.terminate()
  393. await expect(handle.write('late')).rejects.toThrow('has exited')
  394. })
  395. it('keeps the shell alive until forced descendants leave', async () => {
  396. vi.useFakeTimers()
  397. const pty = new FakePty()
  398. const inspector = new FakeInspector()
  399. inspector.members = [{ pid: 124, started: 'child' }]
  400. inspector.alive.add(124)
  401. inspector.removeOnSignal = false
  402. const handle = makeHandle(pty, inspector, 20)
  403. const quiescent = handle.terminate()
  404. expect(handle.terminate()).toBe(quiescent)
  405. await vi.advanceTimersByTimeAsync(20)
  406. expect(inspector.processes).toContainEqual([124, 'SIGKILL'])
  407. expect(pty.kills).toEqual([])
  408. inspector.alive.delete(124)
  409. await vi.advanceTimersByTimeAsync(20)
  410. await quiescent
  411. expect(pty.kills).toEqual(['SIGTERM'])
  412. })
  413. it('keeps an early exit wait pending through descendant cleanup', async () => {
  414. vi.useFakeTimers()
  415. const pty = new FakePty()
  416. const inspector = new FakeInspector()
  417. inspector.members = [{ pid: 124, started: 'child' }]
  418. inspector.alive.add(124)
  419. inspector.removeOnSignal = false
  420. const handle = makeHandle(pty, inspector, 20)
  421. pty.emitExit()
  422. const waiting = handle.terminate()
  423. let settled = false
  424. void waiting.then(() => { settled = true })
  425. await vi.advanceTimersByTimeAsync(10)
  426. expect(settled).toBe(false)
  427. inspector.alive.delete(124)
  428. await vi.advanceTimersByTimeAsync(20)
  429. await waiting
  430. })
  431. it('cleans a same-session descendant after the top-level shell exits naturally', async () => {
  432. const pty = new FakePty()
  433. const inspector = new FakeInspector()
  434. const disowned = { pid: 124, started: 'disowned' }
  435. inspector.readSession = () => inspector.alive.has(disowned.pid) ? [disowned] : []
  436. inspector.alive.add(124)
  437. const handle = makeHandle(pty, inspector, 20)
  438. pty.emitExit()
  439. await handle.terminate()
  440. expect(inspector.processes).toEqual([[124, 'SIGTERM']])
  441. })
  442. it('retains an inspected descendant after it reparents away from the shell', async () => {
  443. const pty = new FakePty()
  444. const inspector = new FakeInspector()
  445. const descendant = { pid: 124, started: 'observed' }
  446. inspector.members = [descendant]
  447. inspector.alive.add(descendant.pid)
  448. const handle = makeHandle(pty, inspector, 20)
  449. await handle.inspectForeground()
  450. inspector.members = []
  451. pty.emitExit()
  452. await handle.terminate()
  453. expect(inspector.processes).toEqual([[124, 'SIGTERM']])
  454. })
  455. it('does not adopt the children of a recycled shell pid', async () => {
  456. const pty = new FakePty()
  457. const inspector = new FakeInspector()
  458. const handle = makeHandle(pty, inspector, 10)
  459. pty.emitExit()
  460. const imposterChild = { pid: 999, started: 'imposter-child' }
  461. inspector.root = { pid: 123, started: 'imposter' }
  462. inspector.members = [imposterChild]
  463. inspector.alive.add(imposterChild.pid)
  464. await handle.terminate()
  465. expect(inspector.processes).toEqual([])
  466. })
  467. it('adopts nothing when the shell identity was never observable', async () => {
  468. const pty = new FakePty()
  469. const inspector = new FakeInspector()
  470. inspector.root = undefined
  471. const orphan = { pid: 321, started: 'unverifiable' }
  472. inspector.members = [orphan]
  473. inspector.alive.add(orphan.pid)
  474. const handle = makeHandle(pty, inspector, 10)
  475. await handle.terminate()
  476. expect(inspector.processes).toEqual([])
  477. expect(pty.kills).toEqual(['SIGTERM'])
  478. })
  479. it('rescans for descendants forked during TERM', async () => {
  480. const pty = new FakePty()
  481. const inspector = new FakeInspector()
  482. const root = { pid: 123, started: 'shell' }
  483. let reads = 0
  484. inspector.readTree = () => {
  485. reads += 1
  486. if (reads === 1) return [root]
  487. if (reads === 2) {
  488. inspector.alive.add(124)
  489. return [root, { pid: 124, started: 'first' }]
  490. }
  491. if (reads === 3) {
  492. inspector.alive.add(125)
  493. return [root, { pid: 125, started: 'late' }]
  494. }
  495. return []
  496. }
  497. const handle = makeHandle(pty, inspector, 10)
  498. await handle.terminate()
  499. expect(inspector.processes).toEqual([[124, 'SIGTERM'], [125, 'SIGKILL']])
  500. expect(pty.kills).toEqual(['SIGTERM'])
  501. })
  502. it('sweeps a same-session descendant forked while the shell handles TERM', async () => {
  503. const pty = new FakePty()
  504. const inspector = new FakeInspector()
  505. const late = { pid: 124, started: 'shell-term-trap' }
  506. pty.onKill = () => {
  507. inspector.sessionMembers = [late]
  508. inspector.alive.add(late.pid)
  509. }
  510. const handle = makeHandle(pty, inspector, 10)
  511. await handle.terminate()
  512. expect(inspector.processes).toEqual([[late.pid, 'SIGTERM']])
  513. expect(pty.kills).toEqual(['SIGTERM'])
  514. })
  515. it('retries failed cleanup after a surviving descendant leaves', async () => {
  516. vi.useFakeTimers()
  517. const pty = new FakePty()
  518. const inspector = new FakeInspector()
  519. const late = { pid: 124, started: 'shell-term-survivor' }
  520. inspector.removeOnSignal = false
  521. pty.onKill = () => {
  522. inspector.sessionMembers = [late]
  523. inspector.alive.add(late.pid)
  524. }
  525. const handle = makeHandle(pty, inspector, 10)
  526. const first = handle.terminate()
  527. const failed = expect(first).rejects.toThrow('surviving pids: 124')
  528. await vi.advanceTimersByTimeAsync(25)
  529. await failed
  530. inspector.alive.delete(late.pid)
  531. const retry = handle.terminate()
  532. expect(retry).not.toBe(first)
  533. await retry
  534. expect(inspector.processes).toEqual([[late.pid, 'SIGTERM'], [late.pid, 'SIGKILL']])
  535. })
  536. it('retains captured descendants after reparenting', async () => {
  537. vi.useFakeTimers()
  538. const pty = new FakePty()
  539. const inspector = new FakeInspector()
  540. const captured = { pid: 124, started: 'captured' }
  541. const root = { pid: 123, started: 'shell' }
  542. let reads = 0
  543. inspector.alive.add(captured.pid)
  544. inspector.readTree = () => { reads += 1; return reads === 1 ? [root] : reads === 2 ? [root, captured] : [] }
  545. inspector.signalProcess = (identity, signal) => {
  546. inspector.processes.push([identity.pid, signal])
  547. if (signal === 'SIGKILL') inspector.alive.delete(identity.pid)
  548. }
  549. const handle = makeHandle(pty, inspector, 20)
  550. const quiescent = handle.terminate()
  551. await vi.advanceTimersByTimeAsync(25)
  552. await quiescent
  553. expect(inspector.processes).toEqual([[124, 'SIGTERM'], [124, 'SIGKILL']])
  554. })
  555. it('reports a top-level process that ignores escalation', async () => {
  556. vi.useFakeTimers()
  557. const pty = new FakePty()
  558. pty.autoExitOnKill = false
  559. const handle = makeHandle(pty, new FakeInspector(), 10)
  560. const failed = expect(handle.terminate()).rejects.toThrow('surviving pid: 123')
  561. await vi.advanceTimersByTimeAsync(25)
  562. await failed
  563. expect(pty.kills).toEqual(['SIGTERM', 'SIGKILL'])
  564. pty.emitExit(0, 999)
  565. expect(await handle.done).toEqual({ exitCode: null, signal: null })
  566. await handle.terminate()
  567. })
  568. it('contains process races while reporting surviving descendants', async () => {
  569. const pty = new FakePty()
  570. pty.throwKill = true
  571. const inspector = new FakeInspector()
  572. inspector.members = [{ pid: 124, started: 'child' }]
  573. inspector.alive.add(124)
  574. inspector.throwProcess = true
  575. const handle = makeHandle(pty, inspector, 1)
  576. await expect(handle.terminate()).rejects.toThrow('surviving pids: 124')
  577. })
  578. })
  579. describe('LocalTerminalHandle on Windows', () => {
  580. const win32 = 'win32' as NodeJS.Platform
  581. it('delivers SIGINT as a Ctrl-C input write without inspector signalling', async () => {
  582. const pty = new FakePty()
  583. const inspector = new FakeInspector()
  584. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32)
  585. await expect(handle.signalForeground('SIGINT')).resolves.toBe(456)
  586. expect(pty.writes).toEqual(['\x03'])
  587. expect(inspector.groups).toEqual([])
  588. })
  589. it('rejects SIGTSTP and SIGHUP as unavailable on Windows', async () => {
  590. const handle = new LocalTerminalHandle(new FakePty().asPty(), new FakeInspector(), 10, win32)
  591. await expect(handle.signalForeground('SIGTSTP')).rejects.toThrow('unsupported on Windows')
  592. await expect(handle.signalForeground('SIGHUP')).rejects.toThrow('unsupported on Windows')
  593. })
  594. it('routes SIGTERM through the inspector tree with the pseudo foreground group', async () => {
  595. const pty = new FakePty()
  596. const inspector = new FakeInspector()
  597. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32)
  598. await expect(handle.signalForeground('SIGTERM')).resolves.toBe(456)
  599. expect(inspector.groups).toEqual([[456, 'SIGTERM']])
  600. expect(pty.writes).toEqual([])
  601. })
  602. it('still refuses to SIGKILL the terminal shell on Windows', async () => {
  603. const pty = new FakePty()
  604. const inspector = new FakeInspector()
  605. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32)
  606. inspector.pgid = handle.pid
  607. await expect(handle.signalForeground('SIGKILL')).rejects.toThrow('terminate the terminal session')
  608. })
  609. it('escalates the shell through taskkill tiers instead of node-pty signal kills', async () => {
  610. vi.useFakeTimers()
  611. const pty = new FakePty()
  612. const inspector = new FakeInspector()
  613. inspector.alive.add(123)
  614. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32)
  615. const quiescent = handle.terminate()
  616. await vi.advanceTimersByTimeAsync(5)
  617. expect(inspector.processes).toEqual([[123, 'SIGTERM']])
  618. expect(pty.kills).toEqual([])
  619. pty.emitExit()
  620. await quiescent
  621. expect(inspector.processes).toEqual([[123, 'SIGTERM']])
  622. expect(pty.kills).toEqual([])
  623. })
  624. it('reports a shell that survives both taskkill tiers', async () => {
  625. vi.useFakeTimers()
  626. const pty = new FakePty()
  627. const inspector = new FakeInspector()
  628. inspector.alive.add(123)
  629. inspector.removeOnSignal = false
  630. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32)
  631. const failed = expect(handle.terminate()).rejects.toThrow('surviving pid: 123')
  632. await vi.advanceTimersByTimeAsync(25)
  633. await failed
  634. expect(inspector.processes).toEqual([[123, 'SIGTERM'], [123, 'SIGKILL']])
  635. expect(pty.kills).toEqual([])
  636. pty.emitExit()
  637. await handle.terminate()
  638. })
  639. it('skips taskkill escalation entirely when the shell already exited', async () => {
  640. const pty = new FakePty()
  641. const inspector = new FakeInspector()
  642. inspector.alive.add(123)
  643. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32)
  644. pty.emitExit()
  645. await handle.terminate()
  646. expect(inspector.processes).toEqual([])
  647. expect(pty.kills).toEqual([])
  648. })
  649. it('falls back to the bare node-pty kill when the shell identity was never observable', async () => {
  650. const pty = new FakePty()
  651. const inspector = new FakeInspector()
  652. inspector.root = undefined
  653. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32)
  654. await handle.terminate()
  655. expect(pty.kills).toHaveLength(1)
  656. expect(inspector.processes).toEqual([])
  657. })
  658. })
  659. describe('signalling freshness and containment', () => {
  660. it('keeps synchronous host exit going when the process table cannot be captured', () => {
  661. const pty = new FakePty()
  662. const inspector = new FakeInspector()
  663. inspector.alive.add(pty.pid)
  664. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
  665. inspector.snapshot = () => { throw new Error('process table unavailable') }
  666. expect(() => { handle.terminateForHostExit() }).not.toThrow()
  667. // forceStopShell still runs: a failed scan must not cost the PTY root.
  668. expect(inspector.processes).toEqual([[pty.pid, 'SIGKILL']])
  669. })
  670. it('captures no process table for a signalling round with no members', () => {
  671. const pty = new FakePty()
  672. const inspector = new FakeInspector()
  673. inspector.alive.add(pty.pid)
  674. const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
  675. // Only the shell exists, so every descendant scan yields an empty round.
  676. inspector.readTree = () => [{ pid: pty.pid, started: 'shell' }]
  677. inspector.captures = 0
  678. handle.terminateForHostExit()
  679. // Two descendant scans and nothing else: no capture for either empty
  680. // signalling round, and none for the identity-fenced shell kill.
  681. expect(inspector.captures).toBe(2)
  682. })
  683. })
  684. describe('process-table read amplification', () => {
  685. // The macOS inspector answers every question by forking `/bin/ps`, so a
  686. // readiness poll that asks per descendant scales its blocking cost with the
  687. // command's process tree. These pin the read count, not the wall time.
  688. function darwinInternals(table: string): { internals: ProcessInspectorInternals; tableReads: string[] } {
  689. const tableReads: string[] = []
  690. const unreachable = (): never => { throw new Error('darwin inspection uses exec and kill only') }
  691. return {
  692. tableReads,
  693. internals: {
  694. readFile: unreachable,
  695. readDir: unreachable,
  696. readLink: unreachable,
  697. stat: unreachable,
  698. open: unreachable,
  699. read: unreachable,
  700. close: unreachable,
  701. exec(_file, args) {
  702. if (args.includes('tpgid=')) return '456\n'
  703. tableReads.push(args.join(' '))
  704. return table
  705. },
  706. kill() {},
  707. },
  708. }
  709. }
  710. /** A shell at pid 123 with `count` descendants chained beneath it. */
  711. function shellTable(count: number): string {
  712. const rows = [' 123 1 Mon Jul 21 10:00:00 2026']
  713. for (let index = 0; index < count; index += 1) {
  714. rows.push(` ${String(124 + index)} ${String(123 + index)} Mon Jul 21 10:00:${String(index + 1).padStart(2, '0')} 2026`)
  715. }
  716. return `${rows.join('\n')}\n`
  717. }
  718. async function tableReadsForOnePoll(descendants: number): Promise<number> {
  719. const { internals, tableReads } = darwinInternals(shellTable(descendants))
  720. const inspector = createProcessInspector('darwin', 'arm64', internals)
  721. const handle = new LocalTerminalHandle(new FakePty().asPty(), inspector, 10, 'darwin')
  722. tableReads.length = 0
  723. const foreground = await handle.inspectForeground()
  724. expect(foreground).toEqual({ processGroupId: 456, inputWaiting: false })
  725. return tableReads.length
  726. }
  727. it('reads the macOS process table once per foreground inspection regardless of descendant count', async () => {
  728. expect(await tableReadsForOnePoll(0)).toBe(1)
  729. expect(await tableReadsForOnePoll(2)).toBe(1)
  730. expect(await tableReadsForOnePoll(10)).toBe(1)
  731. })
  732. })