terminal.spec.ts 39 KB

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