session.spec.ts 65 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601
  1. import { afterEach, describe, expect, it, vi } from 'vitest'
  2. import { PassThrough } from 'node:stream'
  3. import { LocalPtySession } from '@deepseek-ai/dsh-terminal-bash/src/session.ts'
  4. import type { ResolvedConfig } from '@deepseek-ai/dsh-terminal-bash/src/config.ts'
  5. import type { TerminalSendOperation, TerminalSessionStatus, TerminalSignal } from '@deepseek-ai/dsh-terminal'
  6. import type {
  7. SubprocessOutcome,
  8. SubprocessTerminalHandle,
  9. SubprocessTerminalSignal,
  10. } from '@deepseek-ai/dsh-subprocess'
  11. import { TerminalError } from '@deepseek-ai/dsh-terminal'
  12. import type {
  13. ProcessIdentity,
  14. ProcessInspector,
  15. } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts'
  16. class FakeInspector implements ProcessInspector {
  17. pgid: number | undefined = 456
  18. waiting = false
  19. members: ProcessIdentity[] = []
  20. alive = new Set<number>()
  21. groups: Array<[number, TerminalSignal]> = []
  22. processes: Array<[number, 'SIGTERM' | 'SIGKILL']> = []
  23. throwGroup = false
  24. throwProcess = false
  25. removeOnSignal = true
  26. foregroundPgid() { return this.pgid }
  27. isStdinWaiting() { return this.waiting }
  28. processTree() { return this.members }
  29. processSession() { return [] }
  30. isAlive(identity: ProcessIdentity) { return this.alive.has(identity.pid) }
  31. signalGroup(pgid: number, signal: TerminalSignal) {
  32. if (this.throwGroup) throw new Error('group failed')
  33. this.groups.push([pgid, signal])
  34. }
  35. signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL') {
  36. if (this.throwProcess) throw new Error('process raced')
  37. this.processes.push([identity.pid, signal])
  38. if (this.removeOnSignal) this.alive.delete(identity.pid)
  39. }
  40. }
  41. class FakeTerminal implements SubprocessTerminalHandle {
  42. pid = 123
  43. readonly output = new PassThrough()
  44. readonly writes: string[] = []
  45. readonly kills: string[] = []
  46. readonly outcome = Promise.withResolvers<SubprocessOutcome>()
  47. readonly done = this.outcome.promise
  48. throwWrite = false
  49. throwKill = false
  50. autoExitOnKill = true
  51. terminateError: Error | undefined
  52. private cleanup: Promise<void> | undefined
  53. constructor(public inspector = new FakeInspector()) {}
  54. emitData(data: string): void {
  55. this.output.write(Buffer.from(data, 'utf8'))
  56. }
  57. emitBytes(data: Uint8Array): void {
  58. this.output.write(data)
  59. }
  60. emitError(error: Error): void {
  61. this.output.emit('error', error)
  62. }
  63. emitFailure(error: unknown): void {
  64. this.output.end()
  65. this.outcome.reject(error)
  66. }
  67. emitExit(exitCode = 0, signal?: number): void {
  68. this.output.end()
  69. this.outcome.resolve({
  70. exitCode: signal === undefined || signal === 0 ? exitCode : null,
  71. signal: signal === 9 ? 'SIGKILL' : signal === 15 ? 'SIGTERM' : null,
  72. })
  73. }
  74. async write(data: string): Promise<void> {
  75. if (this.throwWrite) throw new Error('write failed')
  76. this.writes.push(data)
  77. }
  78. async inspectForeground() {
  79. const processGroupId = this.inspector.foregroundPgid()
  80. return processGroupId === undefined
  81. ? undefined
  82. : { processGroupId, inputWaiting: this.inspector.isStdinWaiting() }
  83. }
  84. async signalForeground(signal: SubprocessTerminalSignal): Promise<number> {
  85. const foreground = await this.inspectForeground()
  86. if (foreground === undefined) throw new Error(`cannot resolve foreground process group for terminal ${this.pid}`)
  87. if (signal === 'SIGKILL' && foreground.processGroupId === this.pid) {
  88. throw new Error('refusing to SIGKILL the terminal shell; terminate the terminal session instead')
  89. }
  90. this.inspector.signalGroup(foreground.processGroupId, signal)
  91. return foreground.processGroupId
  92. }
  93. terminate(): Promise<void> {
  94. if (this.cleanup !== undefined) return this.cleanup
  95. const cleanup = this.terminateOnce()
  96. this.cleanup = cleanup
  97. void cleanup.catch(() => { this.cleanup = undefined })
  98. return cleanup
  99. }
  100. private async terminateOnce(): Promise<void> {
  101. if (this.terminateError !== undefined) throw this.terminateError
  102. if (this.throwKill) throw new Error('kill failed')
  103. this.kills.push('SIGTERM')
  104. if (this.autoExitOnKill) this.emitExit(0, 15)
  105. }
  106. }
  107. function makeSession(
  108. terminal: FakeTerminal,
  109. inspector: FakeInspector,
  110. resolved: ResolvedConfig,
  111. ): LocalPtySession {
  112. terminal.inspector = inspector
  113. return new LocalPtySession(terminal, resolved)
  114. }
  115. function config(overrides: Partial<ResolvedConfig> = {}): ResolvedConfig {
  116. return {
  117. backendType: 'shell', shellDialect: 'bash', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80,
  118. scrollbackLines: 10, scrollbackMaxBytes: 128, maxReadBytes: 64,
  119. pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 50, handoffGraceMs: 10, timeoutMs: 100,
  120. disposeGraceMs: 20,
  121. ...overrides,
  122. }
  123. }
  124. afterEach(() => { vi.useRealTimers() })
  125. async function initialize(session: LocalPtySession, terminal: FakeTerminal): Promise<void> {
  126. const pending = session.initialize()
  127. terminal.emitData('\x1b]133;D;0\x07dsh> ')
  128. await vi.advanceTimersByTimeAsync(10)
  129. await pending
  130. }
  131. describe('LocalPtySession readiness and output', () => {
  132. it('answers split cursor-position queries before publishing prompt readiness', async () => {
  133. vi.useFakeTimers()
  134. const terminal = new FakeTerminal()
  135. const inspector = new FakeInspector()
  136. const session = makeSession(terminal, inspector, config())
  137. const responseGate = Promise.withResolvers<undefined>()
  138. terminal.write = async (data) => {
  139. terminal.writes.push(data)
  140. await responseGate.promise
  141. }
  142. let initialized = false
  143. const pending = session.initialize().then(() => { initialized = true })
  144. terminal.emitData('\x1b]133;D;0\x07dsh> \x1b[')
  145. terminal.emitData('6n')
  146. await vi.advanceTimersByTimeAsync(20)
  147. expect(terminal.writes).toContain('\x1b[1;6R')
  148. expect(initialized).toBe(false)
  149. responseGate.resolve(undefined)
  150. await vi.advanceTimersByTimeAsync(10)
  151. await pending
  152. expect(session.motd).toBe('dsh> ')
  153. })
  154. it('drains terminal replies before caller input and re-inspects after concurrent output', async () => {
  155. vi.useFakeTimers()
  156. const terminal = new FakeTerminal()
  157. const inspector = new FakeInspector()
  158. const session = makeSession(terminal, inspector, config())
  159. await initialize(session, terminal)
  160. const firstInspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
  161. const secondInspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
  162. let inspections = 0
  163. terminal.inspectForeground = async () => {
  164. inspections += 1
  165. if (inspections === 1) return await firstInspection.promise
  166. if (inspections === 2) return await secondInspection.promise
  167. return { processGroupId: 456, inputWaiting: false }
  168. }
  169. const responseGate = Promise.withResolvers<undefined>()
  170. terminal.write = async (data) => {
  171. terminal.writes.push(data)
  172. if (data === '\x1b[1;6R') await responseGate.promise
  173. }
  174. const operation = session.startSend({ text: 'caller input', submit: true })
  175. await Promise.resolve()
  176. terminal.emitData('\x1b[6n')
  177. await vi.advanceTimersByTimeAsync(0)
  178. firstInspection.resolve({ processGroupId: 456, inputWaiting: true })
  179. await Promise.resolve()
  180. expect(terminal.writes).toEqual(['\x1b[1;6R'])
  181. responseGate.resolve(undefined)
  182. await vi.advanceTimersByTimeAsync(0)
  183. expect(inspections).toBe(2)
  184. terminal.emitData('\x1b[6n')
  185. await vi.advanceTimersByTimeAsync(0)
  186. secondInspection.resolve({ processGroupId: 456, inputWaiting: true })
  187. await vi.advanceTimersByTimeAsync(0)
  188. expect(inspections).toBe(3)
  189. expect(terminal.writes).toEqual(['\x1b[1;6R', '\x1b[1;6R', 'caller input\r'])
  190. terminal.emitData('\x1b]133;D;0\x07dsh> ')
  191. await vi.advanceTimersByTimeAsync(10)
  192. expect((await operation.done).waitReason).toBe('stdin_read')
  193. })
  194. it('drains a terminal reply that is pending when caller input starts', async () => {
  195. vi.useFakeTimers()
  196. const terminal = new FakeTerminal()
  197. const inspector = new FakeInspector()
  198. const session = makeSession(terminal, inspector, config())
  199. await initialize(session, terminal)
  200. const responseGate = Promise.withResolvers<undefined>()
  201. terminal.write = async (data) => {
  202. terminal.writes.push(data)
  203. if (data === '\x1b[1;6R') await responseGate.promise
  204. }
  205. terminal.emitData('\x1b[6n')
  206. await vi.advanceTimersByTimeAsync(0)
  207. const operation = session.startSend({ text: 'caller input', submit: true })
  208. await Promise.resolve()
  209. expect(terminal.writes).toEqual(['\x1b[1;6R'])
  210. responseGate.resolve(undefined)
  211. await vi.advanceTimersByTimeAsync(0)
  212. expect(terminal.writes).toEqual(['\x1b[1;6R', 'caller input\r'])
  213. terminal.emitData('\x1b]133;D;0\x07dsh> ')
  214. await vi.advanceTimersByTimeAsync(10)
  215. expect((await operation.done).waitReason).toBe('stdin_read')
  216. })
  217. it('resamples readiness foreground state after protocol activity during inspection', async () => {
  218. vi.useFakeTimers()
  219. const terminal = new FakeTerminal()
  220. const inspector = new FakeInspector()
  221. const session = makeSession(terminal, inspector, config())
  222. await initialize(session, terminal)
  223. const operation = session.startSend({ text: '', submit: false })
  224. await Promise.resolve()
  225. await Promise.resolve()
  226. const internal = session as unknown as {
  227. stopReadinessPolling(): void
  228. pollReadiness(operation: TerminalSendOperation): Promise<void>
  229. settleActive(reason: 'timeout'): void
  230. }
  231. internal.stopReadinessPolling()
  232. await vi.advanceTimersByTimeAsync(20)
  233. const firstInspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
  234. let inspections = 0
  235. terminal.inspectForeground = async () => {
  236. inspections += 1
  237. return inspections === 1
  238. ? await firstInspection.promise
  239. : { processGroupId: 456, inputWaiting: false }
  240. }
  241. const polling = internal.pollReadiness(operation)
  242. await Promise.resolve()
  243. terminal.emitData('\x1b[6n')
  244. await vi.advanceTimersByTimeAsync(0)
  245. firstInspection.resolve({ processGroupId: 456, inputWaiting: true })
  246. await polling
  247. expect(inspections).toBe(2)
  248. expect((operation as unknown as { settled: boolean }).settled).toBe(false)
  249. internal.settleActive('timeout')
  250. expect((await operation.done).waitReason).toBe('timeout')
  251. })
  252. it('retains send ownership while a failed inspection drains a terminal reply', async () => {
  253. vi.useFakeTimers()
  254. const terminal = new FakeTerminal()
  255. const inspector = new FakeInspector()
  256. const session = makeSession(terminal, inspector, config())
  257. await initialize(session, terminal)
  258. const operation = session.startSend({ text: '', submit: false })
  259. await Promise.resolve()
  260. await Promise.resolve()
  261. const internal = session as unknown as {
  262. stopReadinessPolling(): void
  263. pollReadiness(operation: TerminalSendOperation): Promise<void>
  264. }
  265. internal.stopReadinessPolling()
  266. const inspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
  267. terminal.inspectForeground = async () => await inspection.promise
  268. const responseGate = Promise.withResolvers<undefined>()
  269. terminal.write = async (data) => {
  270. terminal.writes.push(data)
  271. await responseGate.promise
  272. }
  273. const polling = internal.pollReadiness(operation)
  274. await Promise.resolve()
  275. terminal.emitData('\x1b[6n')
  276. await vi.advanceTimersByTimeAsync(0)
  277. inspection.reject(new Error('inspection failed with reply pending'))
  278. await Promise.resolve()
  279. expect(() => session.startSend({ text: 'successor', submit: true })).toThrow('active send')
  280. responseGate.resolve(undefined)
  281. await polling
  282. await expect(operation.done).rejects.toThrow('inspection failed with reply pending')
  283. })
  284. it('retains pre-write ownership when inspection fails with a terminal reply pending', async () => {
  285. vi.useFakeTimers()
  286. const terminal = new FakeTerminal()
  287. const inspector = new FakeInspector()
  288. const session = makeSession(terminal, inspector, config())
  289. await initialize(session, terminal)
  290. const inspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
  291. terminal.inspectForeground = async () => await inspection.promise
  292. const responseGate = Promise.withResolvers<undefined>()
  293. terminal.write = async (data) => {
  294. terminal.writes.push(data)
  295. await responseGate.promise
  296. }
  297. const operation = session.startSend({ text: 'must not execute', submit: true })
  298. await Promise.resolve()
  299. terminal.emitData('\x1b[6n')
  300. await vi.advanceTimersByTimeAsync(0)
  301. inspection.reject(new Error('pre-write inspection failed with reply pending'))
  302. await Promise.resolve()
  303. expect(() => session.startSend({ text: 'successor', submit: true })).toThrow('active send')
  304. responseGate.resolve(undefined)
  305. await expect(operation.done).rejects.toThrow('pre-write inspection failed with reply pending')
  306. expect(terminal.writes).toEqual(['\x1b[1;6R'])
  307. })
  308. it('retains a timed-out send until its terminal-protocol response settles', async () => {
  309. vi.useFakeTimers()
  310. const terminal = new FakeTerminal()
  311. const inspector = new FakeInspector()
  312. const session = makeSession(terminal, inspector, config())
  313. await initialize(session, terminal)
  314. const responseGate = Promise.withResolvers<undefined>()
  315. terminal.write = async (data) => {
  316. terminal.writes.push(data)
  317. await responseGate.promise
  318. }
  319. const operation = session.startSend({ text: '', submit: false })
  320. terminal.emitData('\x1b[6n')
  321. await vi.advanceTimersByTimeAsync(100)
  322. expect((await operation.done).waitReason).toBe('timeout')
  323. expect(() => session.startSend({ text: 'successor', submit: true })).toThrow('active send')
  324. responseGate.resolve(undefined)
  325. await vi.advanceTimersByTimeAsync(0)
  326. const successor = session.startSend({ text: '', submit: false })
  327. terminal.emitData('\x1b]133;D;0\x07dsh> ')
  328. await vi.advanceTimersByTimeAsync(10)
  329. expect((await successor.done).waitReason).toBe('stdin_read')
  330. })
  331. it('contains terminal emulator and protocol-response failures', async () => {
  332. const responseTerminal = new FakeTerminal()
  333. responseTerminal.throwWrite = true
  334. const responseSession = new LocalPtySession(responseTerminal, config())
  335. const responseOperation = responseSession.startSend({ text: '', submit: false })
  336. responseTerminal.emitData('\x1b[6n')
  337. await expect(responseOperation.done).rejects.toThrow('write failed')
  338. expect(responseSession.status()).toEqual({ kind: 'exited', exitCode: null, signal: null })
  339. const emulatorTerminal = new FakeTerminal()
  340. const emulatorSession = new LocalPtySession(emulatorTerminal, config())
  341. const emulatorOperation = emulatorSession.startSend({ text: '', submit: false })
  342. const emulator = (emulatorSession as unknown as {
  343. emulator: { write(data: string, callback?: () => void): void }
  344. }).emulator
  345. emulator.write = () => { throw new Error('emulator failed') }
  346. emulatorTerminal.emitData('output')
  347. await expect(emulatorOperation.done).rejects.toThrow('emulator failed')
  348. expect(emulatorSession.status()).toEqual({ kind: 'exited', exitCode: null, signal: null })
  349. })
  350. it('ignores terminal-protocol failures after closing starts and drains changing queues', async () => {
  351. const terminal = new FakeTerminal()
  352. const session = new LocalPtySession(terminal, config())
  353. const internal = session as unknown as {
  354. closing: boolean
  355. emulator: { write(data: string, callback?: () => void): void }
  356. emulatorWrites: Promise<void>
  357. responseWrites: Promise<void>
  358. drainTerminalProtocol(): Promise<void>
  359. closeEmulator(): void
  360. }
  361. internal.closing = true
  362. terminal.throwWrite = true
  363. terminal.emitData('\x1b[6n')
  364. await internal.emulatorWrites
  365. await internal.responseWrites
  366. expect(session.status()).toEqual({ kind: 'running' })
  367. internal.emulator.write = () => { throw new Error('late emulator failure') }
  368. terminal.emitData('late output')
  369. await internal.emulatorWrites
  370. expect(session.status()).toEqual({ kind: 'running' })
  371. const first = Promise.withResolvers<undefined>()
  372. internal.emulatorWrites = first.promise
  373. const draining = internal.drainTerminalProtocol()
  374. internal.emulatorWrites = Promise.resolve()
  375. first.resolve(undefined)
  376. await draining
  377. internal.closeEmulator()
  378. internal.closeEmulator()
  379. terminal.emitData('after emulator close')
  380. expect(session.status()).toEqual({ kind: 'running' })
  381. })
  382. it('coalesces terminal output that arrives while an emulator parse is pending', async () => {
  383. const terminal = new FakeTerminal()
  384. const session = new LocalPtySession(terminal, config())
  385. const writes: Array<{ data: string; done: () => void }> = []
  386. const internal = session as unknown as {
  387. emulator: { write(data: string, callback?: () => void): void }
  388. emulatorWrites: Promise<void>
  389. closeEmulator(): void
  390. }
  391. internal.emulator.write = (data, callback) => {
  392. writes.push({ data, done: callback ?? (() => {}) })
  393. }
  394. terminal.emitData('first')
  395. terminal.emitData('second')
  396. terminal.emitData('third')
  397. await Promise.resolve()
  398. expect(writes.map(write => write.data)).toEqual(['first'])
  399. writes[0]!.done()
  400. await Promise.resolve()
  401. expect(writes.map(write => write.data)).toEqual(['first', 'secondthird'])
  402. writes[1]!.done()
  403. await internal.emulatorWrites
  404. internal.closeEmulator()
  405. })
  406. it('lets queued terminal output run before the first post-write readiness poll', async () => {
  407. vi.useFakeTimers()
  408. const terminal = new FakeTerminal()
  409. const inspector = new FakeInspector()
  410. const session = makeSession(terminal, inspector, config())
  411. await initialize(session, terminal)
  412. const inspect = terminal.inspectForeground.bind(terminal)
  413. let inspections = 0
  414. terminal.inspectForeground = async () => {
  415. inspections += 1
  416. return await inspect()
  417. }
  418. const operation = session.startSend({ text: 'true', submit: true })
  419. await Promise.resolve()
  420. await Promise.resolve()
  421. expect(inspections).toBe(1)
  422. await vi.advanceTimersByTimeAsync(0)
  423. expect(inspections).toBe(1)
  424. terminal.emitData('\x1b]133;D;0\x07dsh> ')
  425. await vi.advanceTimersByTimeAsync(10)
  426. expect((await operation.done).waitReason).toBe('stdin_read')
  427. })
  428. it('discards prompt readiness observed during asynchronous pre-write inspection', async () => {
  429. vi.useFakeTimers()
  430. const terminal = new FakeTerminal()
  431. const session = new LocalPtySession(terminal, config())
  432. await initialize(session, terminal)
  433. const inspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
  434. terminal.inspectForeground = async () => await inspection.promise
  435. const operation = session.startSend({ text: 'long-running-command', submit: true })
  436. let settled = false
  437. void operation.done.then(() => { settled = true })
  438. terminal.emitData('\x1b]133;D;0\x07dsh> ')
  439. inspection.resolve({ processGroupId: 456, inputWaiting: true })
  440. await vi.advanceTimersByTimeAsync(20)
  441. expect(terminal.writes).toEqual(['long-running-command\r'])
  442. expect(settled).toBe(false)
  443. terminal.emitData('\x1b]133;D;0\x07dsh> ')
  444. await vi.advanceTimersByTimeAsync(10)
  445. expect((await operation.done).waitReason).toBe('stdin_read')
  446. })
  447. it('captures prompt MOTD, writes submit explicitly, and settles exact stdin waits', async () => {
  448. vi.useFakeTimers()
  449. const terminal = new FakeTerminal()
  450. const inspector = new FakeInspector()
  451. const session = makeSession(terminal, inspector, config())
  452. await initialize(session, terminal)
  453. expect(session.motd).toBe('dsh> ')
  454. inspector.waiting = true
  455. const operation = session.startSend({ text: 'python3', submit: true })
  456. await Promise.resolve()
  457. await Promise.resolve()
  458. expect(terminal.writes).toEqual(['python3\r'])
  459. inspector.pgid = 789
  460. terminal.emitData('Python\r\n>>> ')
  461. await vi.advanceTimersByTimeAsync(20)
  462. expect(await operation.done).toMatchObject({ waitReason: 'stdin_read', viewport: 'Python\n>>> ', sessionStatus: { kind: 'running' } })
  463. expect(operation.cancel()).toBe(false)
  464. })
  465. it('does not reuse a pre-write stdin wait as post-write readiness', async () => {
  466. vi.useFakeTimers()
  467. const terminal = new FakeTerminal()
  468. const inspector = new FakeInspector()
  469. const session = makeSession(terminal, inspector, config())
  470. await initialize(session, terminal)
  471. inspector.waiting = true
  472. const operation = session.startSend({ text: 'echo ready', submit: true })
  473. let settled = false
  474. void operation.done.then(() => { settled = true })
  475. await vi.advanceTimersByTimeAsync(20)
  476. expect(settled).toBe(false)
  477. inspector.waiting = false
  478. await vi.advanceTimersByTimeAsync(10)
  479. expect(settled).toBe(false)
  480. inspector.waiting = true
  481. await vi.advanceTimersByTimeAsync(10)
  482. expect((await operation.done).waitReason).toBe('stdin_read')
  483. })
  484. it('tracks a pre-write wait exit before exact probing begins', async () => {
  485. vi.useFakeTimers()
  486. const terminal = new FakeTerminal()
  487. const inspector = new FakeInspector()
  488. const session = makeSession(terminal, inspector, config({
  489. exactProbeAfterMs: 50,
  490. idleSilenceMs: 100,
  491. timeoutMs: 200,
  492. }))
  493. await initialize(session, terminal)
  494. inspector.waiting = true
  495. const operation = session.startSend({ text: 'fast command', submit: true })
  496. let settled = false
  497. void operation.done.then(() => { settled = true })
  498. inspector.waiting = false
  499. await vi.advanceTimersByTimeAsync(10)
  500. inspector.waiting = true
  501. await vi.advanceTimersByTimeAsync(30)
  502. expect(settled).toBe(false)
  503. await vi.advanceTimersByTimeAsync(10)
  504. expect(settled).toBe(true)
  505. expect((await operation.done).waitReason).toBe('stdin_read')
  506. })
  507. it('distinguishes inferred idle, timeout, exit signal, and operation reads', async () => {
  508. vi.useFakeTimers()
  509. const terminal = new FakeTerminal()
  510. const inspector = new FakeInspector()
  511. const session = makeSession(terminal, inspector, config())
  512. await initialize(session, terminal)
  513. inspector.pgid = undefined
  514. const inferred = session.startSend({ text: 'sleep', submit: false })
  515. terminal.emitData('working')
  516. expect(inferred.readOutput()).toEqual({ delta: 'working', truncated: false })
  517. await vi.advanceTimersByTimeAsync(60)
  518. expect((await inferred.done).waitReason).toBe('inferred_idle')
  519. const timeout = session.startSend({ text: 'blocked', submit: false })
  520. await vi.advanceTimersByTimeAsync(40)
  521. terminal.emitData('.')
  522. await vi.advanceTimersByTimeAsync(40)
  523. terminal.emitData('.')
  524. await vi.advanceTimersByTimeAsync(30)
  525. expect((await timeout.done).waitReason).toBe('timeout')
  526. const exiting = session.startSend({ text: 'exit', submit: true })
  527. terminal.emitExit(7, 9)
  528. expect(await exiting.done).toMatchObject({ waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: null, signal: 'SIGKILL' } })
  529. expect(() => session.startSend({ text: '', submit: false })).toThrow('has exited')
  530. })
  531. it('cancels with foreground-group SIGINT, observes AbortSignal, and contains write failures', async () => {
  532. vi.useFakeTimers()
  533. const terminal = new FakeTerminal()
  534. const inspector = new FakeInspector()
  535. const session = makeSession(terminal, inspector, config())
  536. await initialize(session, terminal)
  537. const controller = new AbortController()
  538. const operation = session.startSend({ text: 'sleep', submit: true, signal: controller.signal })
  539. expect(() => session.startSend({ text: 'again', submit: true })).toThrow('active send')
  540. controller.abort()
  541. await Promise.resolve()
  542. await Promise.resolve()
  543. expect(inspector.groups).toContainEqual([456, 'SIGINT'])
  544. expect(terminal.writes).not.toContain('\x03')
  545. terminal.emitData('\x1b]133;D;130\x07dsh> ')
  546. await vi.advanceTimersByTimeAsync(10)
  547. await operation.done
  548. const aborted = new AbortController()
  549. aborted.abort()
  550. expect(() => session.startSend({ text: '', submit: false, signal: aborted.signal })).toThrow('aborted before write')
  551. terminal.throwWrite = true
  552. const failed = session.startSend({ text: 'x', submit: false })
  553. await expect(failed.done).rejects.toThrow('write failed')
  554. const failedInternal = failed as unknown as { append(text: string): void; fail(error: unknown): void }
  555. failedInternal.append('ignored')
  556. failedInternal.fail(new Error('ignored'))
  557. })
  558. it('does not write a send canceled during asynchronous foreground inspection', async () => {
  559. vi.useFakeTimers()
  560. const terminal = new FakeTerminal()
  561. const inspector = new FakeInspector()
  562. const session = makeSession(terminal, inspector, config())
  563. await initialize(session, terminal)
  564. const inspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
  565. terminal.inspectForeground = async () => await inspection.promise
  566. const controller = new AbortController()
  567. const operation = session.startSend({ text: 'must not execute', submit: true, signal: controller.signal })
  568. controller.abort()
  569. inspection.resolve({ processGroupId: 456, inputWaiting: false })
  570. await Promise.resolve()
  571. await Promise.resolve()
  572. expect(terminal.writes).toEqual([])
  573. expect(inspector.groups).toContainEqual([456, 'SIGINT'])
  574. terminal.emitData('\x1b]133;D;130\x07dsh> ')
  575. await vi.advanceTimersByTimeAsync(10)
  576. await operation.done
  577. })
  578. it('retains a canceled send when the pre-write inspection rejects while its signal is in flight', async () => {
  579. vi.useFakeTimers()
  580. const terminal = new FakeTerminal()
  581. const inspector = new FakeInspector()
  582. const session = makeSession(terminal, inspector, config())
  583. await initialize(session, terminal)
  584. const failed = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
  585. terminal.inspectForeground = async () => await failed.promise
  586. const uncanceled = session.startSend({ text: 'plain failure', submit: true })
  587. failed.reject(new Error('inspect failed before write'))
  588. await expect(uncanceled.done).rejects.toThrow('inspect failed before write')
  589. terminal.inspectForeground = FakeTerminal.prototype.inspectForeground.bind(terminal)
  590. const inspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
  591. terminal.inspectForeground = async () => await inspection.promise
  592. const signalGate = Promise.withResolvers<undefined>()
  593. terminal.signalForeground = async (signal) => {
  594. await signalGate.promise
  595. inspector.signalGroup(456, signal)
  596. return 456
  597. }
  598. const controller = new AbortController()
  599. const operation = session.startSend({ text: 'must stay owned', submit: true, signal: controller.signal })
  600. controller.abort()
  601. inspection.reject(new Error('transient inspection failure'))
  602. await Promise.resolve()
  603. await Promise.resolve()
  604. // The slot stays reserved while the cancellation's foreground signal is in flight.
  605. expect(() => session.startSend({ text: 'successor', submit: true })).toThrow('active send')
  606. terminal.inspectForeground = async () => ({ processGroupId: 456, inputWaiting: false })
  607. signalGate.resolve(undefined)
  608. await vi.advanceTimersByTimeAsync(0)
  609. expect(inspector.groups).toContainEqual([456, 'SIGINT'])
  610. terminal.emitData('\x1b]133;D;130\x07dsh> ')
  611. await vi.advanceTimersByTimeAsync(10)
  612. await operation.done
  613. })
  614. it('retains a canceled send until asynchronous foreground signalling settles', async () => {
  615. vi.useFakeTimers()
  616. const terminal = new FakeTerminal()
  617. const inspector = new FakeInspector()
  618. const session = makeSession(terminal, inspector, config())
  619. await initialize(session, terminal)
  620. const signalGate = Promise.withResolvers<undefined>()
  621. terminal.signalForeground = async (signal) => {
  622. await signalGate.promise
  623. const foreground = await terminal.inspectForeground()
  624. if (foreground === undefined) throw new Error('cannot resolve foreground')
  625. inspector.signalGroup(foreground.processGroupId, signal)
  626. return foreground.processGroupId
  627. }
  628. const operation = session.startSend({ text: 'first', submit: true })
  629. await Promise.resolve()
  630. await Promise.resolve()
  631. expect(operation.cancel()).toBe(true)
  632. terminal.emitData('\x1b]133;D;130\x07dsh> ')
  633. await vi.advanceTimersByTimeAsync(100)
  634. expect((await operation.done).waitReason).toBe('timeout')
  635. expect(() => session.startSend({ text: 'successor', submit: true })).toThrow('active send')
  636. signalGate.resolve(undefined)
  637. await vi.advanceTimersByTimeAsync(0)
  638. expect(inspector.groups).toContainEqual([456, 'SIGINT'])
  639. expect(inspector.groups).not.toContainEqual([789, 'SIGINT'])
  640. })
  641. it('does not let an in-flight readiness inspection release a canceled send before signalling settles', async () => {
  642. vi.useFakeTimers()
  643. const terminal = new FakeTerminal()
  644. const inspector = new FakeInspector()
  645. const session = makeSession(terminal, inspector, config())
  646. await initialize(session, terminal)
  647. const readiness = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
  648. let inspections = 0
  649. terminal.inspectForeground = async () => {
  650. inspections += 1
  651. if (inspections === 1) return { processGroupId: 456, inputWaiting: false }
  652. if (inspections === 2) return await readiness.promise
  653. return { processGroupId: 456, inputWaiting: true }
  654. }
  655. const signalling = Promise.withResolvers<undefined>()
  656. const signalled = Promise.withResolvers<number>()
  657. terminal.signalForeground = async (signal) => {
  658. await signalling.promise
  659. const foreground = await terminal.inspectForeground()
  660. if (foreground === undefined) throw new Error('cannot resolve foreground')
  661. inspector.signalGroup(foreground.processGroupId, signal)
  662. signalled.resolve(foreground.processGroupId)
  663. return foreground.processGroupId
  664. }
  665. const operation = session.startSend({ text: 'first', submit: true })
  666. await Promise.resolve()
  667. await Promise.resolve()
  668. await vi.advanceTimersByTimeAsync(10)
  669. expect(inspections).toBe(2)
  670. await vi.advanceTimersByTimeAsync(50)
  671. expect(operation.cancel()).toBe(true)
  672. let settled = false
  673. void operation.done.then(() => { settled = true })
  674. readiness.resolve({ processGroupId: 456, inputWaiting: true })
  675. await Promise.resolve()
  676. await Promise.resolve()
  677. expect(settled).toBe(false)
  678. expect(() => session.startSend({ text: 'successor', submit: true })).toThrow(TerminalError)
  679. signalling.resolve(undefined)
  680. expect(await signalled.promise).toBe(456)
  681. expect(inspector.groups).toContainEqual([456, 'SIGINT'])
  682. await session.close('test complete')
  683. expect((await operation.done).waitReason).toBe('session_exit')
  684. })
  685. it('does not let an in-flight readiness failure release a canceled send before signalling settles', async () => {
  686. vi.useFakeTimers()
  687. const terminal = new FakeTerminal()
  688. const inspector = new FakeInspector()
  689. const session = makeSession(terminal, inspector, config())
  690. await initialize(session, terminal)
  691. const readiness = Promise.withResolvers<never>()
  692. let inspections = 0
  693. terminal.inspectForeground = async () => {
  694. inspections += 1
  695. if (inspections === 1) return { processGroupId: 456, inputWaiting: false }
  696. if (inspections === 2) return await readiness.promise
  697. return { processGroupId: 456, inputWaiting: true }
  698. }
  699. const signalling = Promise.withResolvers<undefined>()
  700. const signalled = Promise.withResolvers<number>()
  701. terminal.signalForeground = async (signal) => {
  702. await signalling.promise
  703. const foreground = await terminal.inspectForeground()
  704. if (foreground === undefined) throw new Error('cannot resolve foreground')
  705. inspector.signalGroup(foreground.processGroupId, signal)
  706. signalled.resolve(foreground.processGroupId)
  707. return foreground.processGroupId
  708. }
  709. const operation = session.startSend({ text: 'first', submit: true })
  710. await Promise.resolve()
  711. await Promise.resolve()
  712. await vi.advanceTimersByTimeAsync(10)
  713. expect(inspections).toBe(2)
  714. expect(operation.cancel()).toBe(true)
  715. let settled = false
  716. void operation.done.then(() => { settled = true })
  717. readiness.reject(new Error('inspection failed during cancellation'))
  718. await Promise.resolve()
  719. await Promise.resolve()
  720. expect(settled).toBe(false)
  721. expect(() => session.startSend({ text: 'successor', submit: true })).toThrow(TerminalError)
  722. signalling.resolve(undefined)
  723. expect(await signalled.promise).toBe(456)
  724. expect(inspector.groups).toContainEqual([456, 'SIGINT'])
  725. await session.close('test complete')
  726. expect((await operation.done).waitReason).toBe('session_exit')
  727. })
  728. it('signals only after an in-flight provider write lands', async () => {
  729. vi.useFakeTimers()
  730. const terminal = new FakeTerminal()
  731. const inspector = new FakeInspector()
  732. const session = makeSession(terminal, inspector, config())
  733. await initialize(session, terminal)
  734. const writeGate = Promise.withResolvers<undefined>()
  735. terminal.write = async () => { await writeGate.promise }
  736. const operation = session.startSend({ text: 'must be interrupted', submit: true })
  737. await Promise.resolve()
  738. await Promise.resolve()
  739. expect(operation.cancel()).toBe(true)
  740. await vi.advanceTimersByTimeAsync(20)
  741. expect(inspector.groups).toEqual([])
  742. writeGate.resolve(undefined)
  743. await vi.advanceTimersByTimeAsync(0)
  744. expect(inspector.groups).toContainEqual([456, 'SIGINT'])
  745. terminal.emitData('\x1b]133;D;130\x07dsh> ')
  746. await vi.advanceTimersByTimeAsync(10)
  747. await operation.done
  748. })
  749. it('does not signal when a cancelled provider write rejects', async () => {
  750. vi.useFakeTimers()
  751. const terminal = new FakeTerminal()
  752. const inspector = new FakeInspector()
  753. const session = makeSession(terminal, inspector, config())
  754. await initialize(session, terminal)
  755. const writeGate = Promise.withResolvers<undefined>()
  756. terminal.write = async () => { await writeGate.promise }
  757. const operation = session.startSend({ text: 'rejected write', submit: true })
  758. await Promise.resolve()
  759. await Promise.resolve()
  760. expect(operation.cancel()).toBe(true)
  761. const rejected = expect(operation.done).rejects.toThrow('write failed after cancellation')
  762. writeGate.reject(new Error('write failed after cancellation'))
  763. await rejected
  764. expect(inspector.groups).toEqual([])
  765. const next = session.startSend({ text: '', submit: false })
  766. await vi.advanceTimersByTimeAsync(100)
  767. expect((await next.done).waitReason).toBe('inferred_idle')
  768. })
  769. it('releases a timed-out cancellation after the provider write and signal settle', async () => {
  770. vi.useFakeTimers()
  771. const terminal = new FakeTerminal()
  772. const inspector = new FakeInspector()
  773. const session = makeSession(terminal, inspector, config())
  774. await initialize(session, terminal)
  775. const writeGate = Promise.withResolvers<undefined>()
  776. terminal.write = async () => { await writeGate.promise }
  777. const operation = session.startSend({ text: 'slow cancelled write', submit: true })
  778. await Promise.resolve()
  779. await Promise.resolve()
  780. expect(operation.cancel()).toBe(true)
  781. await vi.advanceTimersByTimeAsync(100)
  782. expect((await operation.done).waitReason).toBe('timeout')
  783. expect(() => session.startSend({ text: 'must wait', submit: true })).toThrow(expect.objectContaining({
  784. code: 'SEND_ACTIVE',
  785. }))
  786. writeGate.resolve(undefined)
  787. await vi.advanceTimersByTimeAsync(0)
  788. expect(inspector.groups).toContainEqual([456, 'SIGINT'])
  789. const next = session.startSend({ text: '', submit: false })
  790. await vi.advanceTimersByTimeAsync(100)
  791. expect((await next.done).waitReason).toBe('inferred_idle')
  792. })
  793. it('retains the absolute timeout after cancellation while output stays active', async () => {
  794. vi.useFakeTimers()
  795. const terminal = new FakeTerminal()
  796. const inspector = new FakeInspector()
  797. const session = makeSession(terminal, inspector, config())
  798. await initialize(session, terminal)
  799. const operation = session.startSend({ text: 'ignore-sigint-and-write', submit: true })
  800. await Promise.resolve()
  801. await Promise.resolve()
  802. expect(operation.cancel()).toBe(true)
  803. for (let elapsed = 20; elapsed <= 100; elapsed += 20) {
  804. terminal.emitData('.')
  805. await vi.advanceTimersByTimeAsync(20)
  806. }
  807. expect(await operation.done).toMatchObject({ waitReason: 'timeout' })
  808. })
  809. it('does not resume cancellation polling after the terminal exits during signalling', async () => {
  810. vi.useFakeTimers()
  811. const terminal = new FakeTerminal()
  812. const inspector = new FakeInspector()
  813. const session = makeSession(terminal, inspector, config())
  814. await initialize(session, terminal)
  815. const signalGate = Promise.withResolvers<undefined>()
  816. terminal.signalForeground = async () => {
  817. await signalGate.promise
  818. return 456
  819. }
  820. const operation = session.startSend({ text: 'first', submit: true })
  821. await Promise.resolve()
  822. await Promise.resolve()
  823. expect(operation.cancel()).toBe(true)
  824. terminal.emitExit(0)
  825. await expect(operation.done).resolves.toMatchObject({ waitReason: 'session_exit' })
  826. signalGate.resolve(undefined)
  827. await vi.advanceTimersByTimeAsync(10)
  828. expect(session.status()).toEqual({ kind: 'exited', exitCode: 0, signal: null })
  829. })
  830. it('retains send ownership after timeout until an asynchronous provider write settles', async () => {
  831. vi.useFakeTimers()
  832. const terminal = new FakeTerminal()
  833. const inspector = new FakeInspector()
  834. const session = makeSession(terminal, inspector, config())
  835. await initialize(session, terminal)
  836. const writeGate = Promise.withResolvers<undefined>()
  837. terminal.write = async () => { await writeGate.promise }
  838. const operation = session.startSend({ text: 'slow write', submit: true })
  839. await Promise.resolve()
  840. await Promise.resolve()
  841. await vi.advanceTimersByTimeAsync(100)
  842. expect((await operation.done).waitReason).toBe('timeout')
  843. expect(() => session.startSend({ text: 'must wait', submit: true })).toThrow('active send')
  844. writeGate.resolve(undefined)
  845. await Promise.resolve()
  846. await Promise.resolve()
  847. const rejectedWrite = Promise.withResolvers<undefined>()
  848. terminal.write = async () => { await rejectedWrite.promise }
  849. const rejected = session.startSend({ text: 'late rejection', submit: true })
  850. await vi.advanceTimersByTimeAsync(100)
  851. expect((await rejected.done).waitReason).toBe('timeout')
  852. rejectedWrite.reject(new Error('write failed after timeout'))
  853. await Promise.resolve()
  854. await Promise.resolve()
  855. const next = session.startSend({ text: '', submit: false })
  856. await vi.advanceTimersByTimeAsync(100)
  857. expect((await next.done).waitReason).toBe('inferred_idle')
  858. })
  859. it('retains send ownership when cancellation signalling fails during an asynchronous write', async () => {
  860. vi.useFakeTimers()
  861. const terminal = new FakeTerminal()
  862. const inspector = new FakeInspector()
  863. const session = makeSession(terminal, inspector, config())
  864. await initialize(session, terminal)
  865. const writeGate = Promise.withResolvers<undefined>()
  866. terminal.write = async () => { await writeGate.promise }
  867. let signalCalls = 0
  868. terminal.signalForeground = async () => {
  869. signalCalls += 1
  870. throw new Error('interrupt failed')
  871. }
  872. const operation = session.startSend({ text: 'slow write', submit: true })
  873. await Promise.resolve()
  874. await Promise.resolve()
  875. expect(operation.cancel()).toBe(true)
  876. await vi.advanceTimersByTimeAsync(20)
  877. expect(signalCalls).toBe(0)
  878. expect(() => session.startSend({ text: 'must wait', submit: true })).toThrow('active send')
  879. writeGate.resolve(undefined)
  880. await expect(operation.done).rejects.toThrow('interrupt failed')
  881. expect(signalCalls).toBe(1)
  882. expect(session.status()).toEqual({ kind: 'exited', exitCode: null, signal: null })
  883. expect(() => session.startSend({ text: '', submit: false })).toThrow('has exited')
  884. })
  885. it('handles startup exit, unknown exit signals, cancel-write failure, and stale polls', async () => {
  886. vi.useFakeTimers()
  887. const startupTerminal = new FakeTerminal()
  888. const startup = new LocalPtySession(startupTerminal, config())
  889. const initializing = startup.initialize(new AbortController().signal)
  890. startupTerminal.emitExit(1)
  891. await expect(initializing).rejects.toThrow('exited during startup')
  892. expect(startup.status()).toEqual({ kind: 'exited', exitCode: 1, signal: null })
  893. const terminal = new FakeTerminal()
  894. const session = new LocalPtySession(terminal, config())
  895. await initialize(session, terminal)
  896. const operation = session.startSend({ text: '', submit: false })
  897. const operationInternal = operation as unknown as {
  898. append(text: string): void
  899. settle(reason: 'timeout', status: TerminalSessionStatus, inherited: boolean): void
  900. }
  901. operationInternal.append('')
  902. const sessionInternal = session as unknown as {
  903. pollReadiness(operation: TerminalSendOperation): void
  904. interrupt(operation: TerminalSendOperation): void
  905. schedulePoll(operation: TerminalSendOperation): void
  906. polling: boolean
  907. statusValue: TerminalSessionStatus
  908. appendOutput(text: string): void
  909. }
  910. sessionInternal.appendOutput('')
  911. sessionInternal.pollReadiness({} as TerminalSendOperation)
  912. sessionInternal.schedulePoll({} as TerminalSendOperation)
  913. sessionInternal.polling = true
  914. sessionInternal.schedulePoll(operation)
  915. sessionInternal.polling = false
  916. sessionInternal.interrupt({} as TerminalSendOperation)
  917. sessionInternal.statusValue = { kind: 'exited', exitCode: 2, signal: null }
  918. sessionInternal.pollReadiness(operation)
  919. await operation.done
  920. operationInternal.settle('timeout', { kind: 'running' }, false)
  921. const unknownTerminal = new FakeTerminal()
  922. const unknown = new LocalPtySession(unknownTerminal, config())
  923. unknownTerminal.emitExit(1, 999)
  924. await vi.waitFor(() => {
  925. expect(unknown.status()).toEqual({ kind: 'exited', exitCode: null, signal: null })
  926. })
  927. const cancelTerminal = new FakeTerminal()
  928. const cancelInspector = new FakeInspector()
  929. const cancel = makeSession(cancelTerminal, cancelInspector, config())
  930. await initialize(cancel, cancelTerminal)
  931. const cancellable = cancel.startSend({ text: '', submit: false })
  932. cancelInspector.throwGroup = true
  933. expect(cancellable.cancel()).toBe(true)
  934. await expect(cancellable.done).rejects.toThrow('group failed')
  935. expect(cancellable.cancel()).toBe(false)
  936. const missingGroupTerminal = new FakeTerminal()
  937. const missingGroupInspector = new FakeInspector()
  938. const missingGroup = makeSession(missingGroupTerminal, missingGroupInspector, config())
  939. await initialize(missingGroup, missingGroupTerminal)
  940. missingGroupInspector.pgid = undefined
  941. const unresolved = missingGroup.startSend({ text: '', submit: false })
  942. expect(unresolved.cancel()).toBe(true)
  943. await expect(unresolved.done).rejects.toThrow('cannot resolve foreground process group')
  944. })
  945. it('does not treat zero-output startup silence as readiness and fails on startup timeout', async () => {
  946. vi.useFakeTimers()
  947. const terminal = new FakeTerminal()
  948. const session = new LocalPtySession(terminal, config())
  949. let settled = false
  950. const initializing = session.initialize().then(() => { settled = true })
  951. await vi.advanceTimersByTimeAsync(60)
  952. expect(settled).toBe(false)
  953. terminal.emitData('\x1b]133;D;0\x07dsh> ')
  954. await vi.advanceTimersByTimeAsync(10)
  955. await initializing
  956. const timeoutTerminal = new FakeTerminal()
  957. const timeout = new LocalPtySession(timeoutTerminal, config())
  958. const timedOut = expect(timeout.initialize()).rejects.toThrow('startup timeout')
  959. await vi.advanceTimersByTimeAsync(100)
  960. await timedOut
  961. })
  962. it('preserves the caller abort reason when startup cannot resolve a foreground group', async () => {
  963. const terminal = new FakeTerminal()
  964. const inspector = new FakeInspector()
  965. inspector.pgid = undefined
  966. const session = makeSession(terminal, inspector, config())
  967. const controller = new AbortController()
  968. const reason = new Error('startup cancelled')
  969. const initializing = session.initialize(controller.signal)
  970. const rejected = expect(initializing).rejects.toBe(reason)
  971. controller.abort(reason)
  972. await rejected
  973. })
  974. it('waits for printable prompt text when the startup marker is split from PS1', async () => {
  975. vi.useFakeTimers()
  976. const terminal = new FakeTerminal()
  977. const session = new LocalPtySession(terminal, config())
  978. let settled = false
  979. const initializing = session.initialize().then(() => { settled = true })
  980. terminal.emitData('\x1b]133;D;0\x07')
  981. await vi.advanceTimersByTimeAsync(20)
  982. expect(settled).toBe(false)
  983. terminal.emitData('dsh> ')
  984. await vi.advanceTimersByTimeAsync(10)
  985. await initializing
  986. expect(session.motd).toBe('dsh> ')
  987. })
  988. it('does not attribute a delayed prior prompt to the current send', async () => {
  989. vi.useFakeTimers()
  990. const terminal = new FakeTerminal()
  991. const session = new LocalPtySession(terminal, config({ idleSilenceMs: 100, timeoutMs: 200 }))
  992. await initialize(session, terminal)
  993. const operation = session.startSend({ text: "printf 'PID=%s\\n' \"$!\"", submit: true })
  994. let settled = false
  995. void operation.done.then(() => { settled = true })
  996. await Promise.resolve()
  997. await Promise.resolve()
  998. terminal.emitData('\x1b]133;D;0\x07dsh> printf \'PID=%s\\n\' "$!"\r\n')
  999. await vi.advanceTimersByTimeAsync(20)
  1000. expect(settled).toBe(false)
  1001. terminal.emitData('PID=123\r\n\x1b]133;D;0\x07dsh> ')
  1002. await vi.advanceTimersByTimeAsync(10)
  1003. expect(await operation.done).toMatchObject({ waitReason: 'stdin_read' })
  1004. })
  1005. it('retains a prompt marker until the startup shell regains the foreground group', async () => {
  1006. vi.useFakeTimers()
  1007. const terminal = new FakeTerminal()
  1008. const inspector = new FakeInspector()
  1009. const session = makeSession(terminal, inspector, config())
  1010. await initialize(session, terminal)
  1011. const operation = session.startSend({ text: 'run', submit: true })
  1012. let settled = false
  1013. void operation.done.then(() => { settled = true })
  1014. await Promise.resolve()
  1015. await Promise.resolve()
  1016. inspector.pgid = 789
  1017. terminal.emitData('\x1b]133;D;0\x07dsh> ')
  1018. await vi.advanceTimersByTimeAsync(50)
  1019. expect(settled).toBe(false)
  1020. inspector.pgid = 456
  1021. await vi.advanceTimersByTimeAsync(10)
  1022. expect(settled).toBe(true)
  1023. expect((await operation.done).waitReason).toBe('stdin_read')
  1024. })
  1025. it('holds the idle fallback for the configured handoff grace, not one poll', async () => {
  1026. vi.useFakeTimers()
  1027. const terminal = new FakeTerminal()
  1028. const inspector = new FakeInspector()
  1029. const session = makeSession(terminal, inspector, config({ handoffGraceMs: 40 }))
  1030. await initialize(session, terminal)
  1031. const operation = session.startSend({ text: 'run', submit: true })
  1032. let settled = false
  1033. void operation.done.then(() => { settled = true })
  1034. await Promise.resolve()
  1035. await Promise.resolve()
  1036. inspector.pgid = 789
  1037. terminal.emitData('\x1b]133;D;0\x07dsh> ')
  1038. // One poll past the silence bound would already have settled inferred_idle.
  1039. await vi.advanceTimersByTimeAsync(70)
  1040. expect(settled).toBe(false)
  1041. inspector.pgid = 456
  1042. await vi.advanceTimersByTimeAsync(10)
  1043. expect((await operation.done).waitReason).toBe('stdin_read')
  1044. })
  1045. it('falls back to inferred idle when a foreground child emits an inherited prompt marker', async () => {
  1046. vi.useFakeTimers()
  1047. const terminal = new FakeTerminal()
  1048. const inspector = new FakeInspector()
  1049. const session = makeSession(terminal, inspector, config())
  1050. await initialize(session, terminal)
  1051. const operation = session.startSend({ text: 'bash -i', submit: true })
  1052. inspector.pgid = 789
  1053. terminal.emitData('\x1b]133;D;0\x07child> ')
  1054. await vi.advanceTimersByTimeAsync(100)
  1055. expect((await operation.done).waitReason).toBe('inferred_idle')
  1056. })
  1057. it('contains terminal transport failures and preserves the first failure', async () => {
  1058. const terminal = new FakeTerminal()
  1059. terminal.terminateError = new Error('cleanup after transport failure')
  1060. const session = new LocalPtySession(terminal, config())
  1061. const operation = session.startSend({ text: '', submit: false })
  1062. terminal.output.emit('data', 'plain text')
  1063. terminal.emitError(new Error('output transport failed'))
  1064. ;(session as unknown as { onTransportFailure(error: unknown): void })
  1065. .onTransportFailure(new Error('later failure'))
  1066. await expect(operation.done).rejects.toThrow('output transport failed')
  1067. expect(session.status()).toEqual({ kind: 'exited', exitCode: null, signal: null })
  1068. terminal.terminateError = undefined
  1069. await expect(session.close('transport')).rejects.toThrow('output transport failed')
  1070. const rejectedTerminal = new FakeTerminal()
  1071. const rejected = new LocalPtySession(rejectedTerminal, config())
  1072. const rejectedOperation = rejected.startSend({ text: '', submit: false })
  1073. rejectedTerminal.emitFailure('raw transport failure')
  1074. await expect(rejectedOperation.done).rejects.toThrow('raw transport failure')
  1075. })
  1076. it('replaces invalid UTF-8 terminal output', async () => {
  1077. const chunkTerminal = new FakeTerminal()
  1078. const chunkSession = new LocalPtySession(chunkTerminal, config())
  1079. const chunkOperation = chunkSession.startSend({ text: '', submit: false })
  1080. chunkTerminal.emitBytes(Uint8Array.from([0xff]))
  1081. expect(chunkOperation.readOutput()).toEqual({ delta: '�', truncated: false })
  1082. chunkTerminal.emitExit()
  1083. await chunkOperation.done
  1084. const endTerminal = new FakeTerminal()
  1085. const endSession = new LocalPtySession(endTerminal, config())
  1086. const endOperation = endSession.startSend({ text: '', submit: false })
  1087. endTerminal.emitBytes(Uint8Array.from([0xe2]))
  1088. endTerminal.emitExit()
  1089. expect((await endOperation.done).viewport).toBe('�')
  1090. })
  1091. it('contains readiness inspection failure and a stale inspection result', async () => {
  1092. vi.useFakeTimers()
  1093. const failedTerminal = new FakeTerminal()
  1094. const failedSession = new LocalPtySession(failedTerminal, config())
  1095. const failedOperation = failedSession.startSend({ text: '', submit: false })
  1096. await Promise.resolve()
  1097. await Promise.resolve()
  1098. failedTerminal.inspectForeground = async () => { throw new Error('inspect failed') }
  1099. const failedInternal = failedSession as unknown as {
  1100. pollReadiness(operation: TerminalSendOperation): Promise<void>
  1101. }
  1102. await failedInternal.pollReadiness(failedOperation)
  1103. await expect(failedOperation.done).rejects.toThrow('inspect failed')
  1104. const staleTerminal = new FakeTerminal()
  1105. const staleSession = new LocalPtySession(staleTerminal, config())
  1106. const staleOperation = staleSession.startSend({ text: '', submit: false })
  1107. await Promise.resolve()
  1108. await Promise.resolve()
  1109. const gate = Promise.withResolvers<ReturnType<FakeTerminal['inspectForeground']> extends Promise<infer T> ? T : never>()
  1110. staleTerminal.inspectForeground = async () => await gate.promise
  1111. const staleInternal = staleSession as unknown as {
  1112. active: TerminalSendOperation | undefined
  1113. pollReadiness(operation: TerminalSendOperation): Promise<void>
  1114. }
  1115. const polling = staleInternal.pollReadiness(staleOperation)
  1116. staleInternal.active = undefined
  1117. gate.resolve({ processGroupId: 456, inputWaiting: false })
  1118. await polling
  1119. ;(staleOperation as unknown as {
  1120. settle(reason: 'timeout', status: TerminalSessionStatus, inherited: boolean): void
  1121. }).settle('timeout', { kind: 'running' }, false)
  1122. })
  1123. it('reschedules readiness for a new send after a stale remote inspection releases the poll slot', async () => {
  1124. vi.useFakeTimers()
  1125. const terminal = new FakeTerminal()
  1126. const inspector = new FakeInspector()
  1127. const session = makeSession(terminal, inspector, config())
  1128. await initialize(session, terminal)
  1129. const old = session.startSend({ text: '', submit: false })
  1130. await Promise.resolve()
  1131. await Promise.resolve()
  1132. const inspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
  1133. let block = true
  1134. terminal.inspectForeground = async () => block
  1135. ? await inspection.promise
  1136. : { processGroupId: 456, inputWaiting: false }
  1137. const internals = session as unknown as {
  1138. pollReadiness(operation: TerminalSendOperation): Promise<void>
  1139. settleActive(reason: 'timeout'): void
  1140. }
  1141. const stalePoll = internals.pollReadiness(old)
  1142. internals.settleActive('timeout')
  1143. await old.done
  1144. block = false
  1145. const current = session.startSend({ text: '', submit: false })
  1146. terminal.emitData('\x1b]133;D;0\x07dsh> ')
  1147. await Promise.resolve()
  1148. await Promise.resolve()
  1149. inspection.resolve({ processGroupId: 456, inputWaiting: false })
  1150. await vi.advanceTimersByTimeAsync(0)
  1151. await stalePoll
  1152. await vi.advanceTimersByTimeAsync(10)
  1153. expect((await current.done).waitReason).toBe('stdin_read')
  1154. })
  1155. it('does not poll a successor before its own pre-write inspection and write complete', async () => {
  1156. vi.useFakeTimers()
  1157. const terminal = new FakeTerminal()
  1158. const inspector = new FakeInspector()
  1159. const session = makeSession(terminal, inspector, config())
  1160. await initialize(session, terminal)
  1161. const old = session.startSend({ text: '', submit: false })
  1162. await Promise.resolve()
  1163. await Promise.resolve()
  1164. const staleInspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
  1165. const successorInspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
  1166. let inspectCalls = 0
  1167. terminal.inspectForeground = async () => {
  1168. inspectCalls += 1
  1169. return inspectCalls === 1 ? await staleInspection.promise : await successorInspection.promise
  1170. }
  1171. const internals = session as unknown as {
  1172. pollReadiness(operation: TerminalSendOperation): Promise<void>
  1173. settleActive(reason: 'timeout'): void
  1174. }
  1175. const stalePoll = internals.pollReadiness(old)
  1176. internals.settleActive('timeout')
  1177. await old.done
  1178. const current = session.startSend({ text: 'successor', submit: true })
  1179. await Promise.resolve()
  1180. await Promise.resolve()
  1181. staleInspection.resolve({ processGroupId: 456, inputWaiting: false })
  1182. await stalePoll
  1183. await vi.advanceTimersByTimeAsync(10)
  1184. expect(inspectCalls).toBe(2)
  1185. expect(terminal.writes).toEqual([])
  1186. successorInspection.resolve({ processGroupId: 456, inputWaiting: false })
  1187. await Promise.resolve()
  1188. await Promise.resolve()
  1189. terminal.emitData('\x1b]133;D;0\x07dsh> ')
  1190. await vi.advanceTimersByTimeAsync(10)
  1191. expect(terminal.writes).toEqual(['successor\r'])
  1192. expect((await current.done).waitReason).toBe('stdin_read')
  1193. })
  1194. it('contains stale timer, write, inspection, and interrupt continuations', async () => {
  1195. vi.useFakeTimers()
  1196. const settle = (operation: TerminalSendOperation): void => {
  1197. ;(operation as unknown as {
  1198. settle(reason: 'timeout', status: TerminalSessionStatus, inherited: boolean): void
  1199. }).settle('timeout', { kind: 'running' }, false)
  1200. }
  1201. const deadlineTerminal = new FakeTerminal()
  1202. const deadlineSession = new LocalPtySession(deadlineTerminal, config())
  1203. const deadlineOperation = deadlineSession.startSend({ text: '', submit: false })
  1204. ;(deadlineSession as unknown as { active: TerminalSendOperation | undefined }).active = undefined
  1205. await vi.advanceTimersByTimeAsync(100)
  1206. settle(deadlineOperation)
  1207. const writeTerminal = new FakeTerminal()
  1208. const writeGate = Promise.withResolvers<undefined>()
  1209. writeTerminal.write = async () => { await writeGate.promise }
  1210. const writeSession = new LocalPtySession(writeTerminal, config())
  1211. const writeOperation = writeSession.startSend({ text: 'x', submit: false })
  1212. await Promise.resolve()
  1213. await Promise.resolve()
  1214. ;(writeSession as unknown as { closing: boolean }).closing = true
  1215. writeGate.resolve(undefined)
  1216. await Promise.resolve()
  1217. await Promise.resolve()
  1218. settle(writeOperation)
  1219. const beginTerminal = new FakeTerminal()
  1220. const beginGate = Promise.withResolvers<never>()
  1221. beginTerminal.inspectForeground = async () => await beginGate.promise
  1222. const beginSession = new LocalPtySession(beginTerminal, config())
  1223. const beginOperation = beginSession.startSend({ text: '', submit: false })
  1224. ;(beginSession as unknown as { active: TerminalSendOperation | undefined }).active = undefined
  1225. beginGate.reject(new Error('stale begin failure'))
  1226. await Promise.resolve()
  1227. await Promise.resolve()
  1228. settle(beginOperation)
  1229. const scheduledTerminal = new FakeTerminal()
  1230. const scheduledSession = new LocalPtySession(scheduledTerminal, config())
  1231. const scheduledOperation = scheduledSession.startSend({ text: '', submit: false })
  1232. await Promise.resolve()
  1233. await Promise.resolve()
  1234. const scheduledInternal = scheduledSession as unknown as {
  1235. schedulePoll(operation: TerminalSendOperation, delayMs?: number): void
  1236. settleActive(reason: 'timeout'): void
  1237. }
  1238. scheduledInternal.schedulePoll(scheduledOperation, 5)
  1239. scheduledInternal.settleActive('timeout')
  1240. await scheduledOperation.done
  1241. const pollTerminal = new FakeTerminal()
  1242. const pollSession = new LocalPtySession(pollTerminal, config())
  1243. const pollOperation = pollSession.startSend({ text: '', submit: false })
  1244. await Promise.resolve()
  1245. await Promise.resolve()
  1246. const pollGate = Promise.withResolvers<never>()
  1247. pollTerminal.inspectForeground = async () => await pollGate.promise
  1248. const pollInternal = pollSession as unknown as {
  1249. active: TerminalSendOperation | undefined
  1250. pollReadiness(operation: TerminalSendOperation): Promise<void>
  1251. }
  1252. const stalePoll = pollInternal.pollReadiness(pollOperation)
  1253. pollInternal.active = undefined
  1254. pollGate.reject(new Error('stale poll failure'))
  1255. await stalePoll
  1256. settle(pollOperation)
  1257. const interruptTerminal = new FakeTerminal()
  1258. const interruptGate = Promise.withResolvers<never>()
  1259. interruptTerminal.signalForeground = async () => await interruptGate.promise
  1260. const interruptSession = new LocalPtySession(interruptTerminal, config())
  1261. const interruptOperation = interruptSession.startSend({ text: '', submit: false })
  1262. expect(interruptOperation.cancel()).toBe(true)
  1263. ;(interruptSession as unknown as { active: TerminalSendOperation | undefined }).active = undefined
  1264. interruptGate.reject(new Error('stale interrupt failure'))
  1265. await Promise.resolve()
  1266. await Promise.resolve()
  1267. settle(interruptOperation)
  1268. })
  1269. })
  1270. describe('LocalPtySession bounds, signals, and teardown', () => {
  1271. it('validates pagination and enforces line/UTF-8 bounds', async () => {
  1272. vi.useFakeTimers()
  1273. const terminal = new FakeTerminal()
  1274. const session = new LocalPtySession(
  1275. terminal,
  1276. config({ scrollbackLines: 3, scrollbackMaxBytes: 12, maxReadBytes: 6 }),
  1277. )
  1278. expect(session.read({})).toMatchObject({ text: '' })
  1279. await initialize(session, terminal)
  1280. const operation = session.startSend({ text: '', submit: false })
  1281. terminal.emitData('一\n二\n三\n四')
  1282. await vi.advanceTimersByTimeAsync(60)
  1283. expect((await operation.done).truncated).toBe(true)
  1284. const page = session.read({ offset: 0, count: 3 })
  1285. expect(Buffer.byteLength(page.text)).toBeLessThanOrEqual(6)
  1286. expect(page.truncated).toBe(true)
  1287. expect(session.read({ offset: 999 })).toMatchObject({ text: '', lineBegin: 999, lineEnd: 999 })
  1288. expect(() => session.read({ offset: -1 })).toThrow('offset')
  1289. expect(() => session.read({ count: 0 })).toThrow('count')
  1290. const tinyTerminal = new FakeTerminal()
  1291. const tiny = new LocalPtySession(tinyTerminal, config({ maxReadBytes: 1 }))
  1292. await initialize(tiny, tinyTerminal)
  1293. const tinyOperation = tiny.startSend({ text: '', submit: false })
  1294. tinyTerminal.emitData('一')
  1295. await vi.advanceTimersByTimeAsync(60)
  1296. await tinyOperation.done
  1297. expect(tiny.read({ offset: 0, count: 1 }).text).toBe('')
  1298. })
  1299. it('signals verified groups and refuses unresolved or shell-targeted hard kills', async () => {
  1300. const terminal = new FakeTerminal()
  1301. const inspector = new FakeInspector()
  1302. const session = makeSession(terminal, inspector, config())
  1303. expect(await session.signal('SIGINT')).toEqual({ delivered: true, targetPgid: 456 })
  1304. inspector.pgid = terminal.pid
  1305. await expect(session.signal('SIGKILL')).rejects.toThrow('terminate the terminal session')
  1306. inspector.pgid = undefined
  1307. await expect(session.signal('SIGTERM')).rejects.toThrow('cannot resolve')
  1308. })
  1309. it('closes idempotently and rejects new signals', async () => {
  1310. const terminal = new FakeTerminal()
  1311. terminal.throwKill = true
  1312. const session = new LocalPtySession(terminal, config({ disposeGraceMs: 1 }))
  1313. const closing = session.close('test')
  1314. expect(session.close('other')).toBe(closing)
  1315. await expect(closing).rejects.toThrow('PTY cleanup failed (test)')
  1316. expect(() => session.startSend({ text: '', submit: false })).toThrow('closing')
  1317. await expect(session.signal('SIGTERM')).rejects.toThrow('closing')
  1318. })
  1319. it('reports cleanup failure without waiting for top-level exit and permits retry', async () => {
  1320. const terminal = new FakeTerminal()
  1321. terminal.autoExitOnKill = false
  1322. terminal.terminateError = new Error('terminal cleanup failed; surviving pids: 456')
  1323. const session = new LocalPtySession(terminal, config())
  1324. await expect(session.close('survivor')).rejects.toMatchObject({
  1325. message: 'PTY cleanup failed (survivor)',
  1326. cause: terminal.terminateError,
  1327. })
  1328. expect((session as unknown as { emulatorClosed: boolean }).emulatorClosed).toBe(true)
  1329. expect(terminal.kills).toEqual([])
  1330. terminal.terminateError = undefined
  1331. terminal.autoExitOnKill = true
  1332. await expect(session.close('retry')).resolves.toBeUndefined()
  1333. expect(terminal.kills).toEqual(['SIGTERM'])
  1334. })
  1335. it('settles an active send as session_exit when closed mid-operation', async () => {
  1336. vi.useFakeTimers()
  1337. const terminal = new FakeTerminal()
  1338. const session = new LocalPtySession(terminal, config({ disposeGraceMs: 50 }))
  1339. await initialize(session, terminal)
  1340. const operation = session.startSend({ text: 'run', submit: true })
  1341. // The shell returns to its prompt while the send is active; a running
  1342. // readiness poll would otherwise mis-settle this as stdin_read once close
  1343. // begins, so teardown must stop polling before its grace period.
  1344. terminal.emitData('\x1b]133;D;0\x07dsh> ')
  1345. terminal.autoExitOnKill = false
  1346. const closing = session.close('mid-send')
  1347. await vi.advanceTimersByTimeAsync(20)
  1348. terminal.emitExit(0, 15)
  1349. expect((await operation.done).waitReason).toBe('session_exit')
  1350. await closing
  1351. })
  1352. it('settles a closing send when provider termination cancels inspection', async () => {
  1353. vi.useFakeTimers()
  1354. const terminal = new FakeTerminal()
  1355. const session = new LocalPtySession(terminal, config())
  1356. await initialize(session, terminal)
  1357. const write = Promise.withResolvers<undefined>()
  1358. terminal.write = async () => { await write.promise }
  1359. const writeOperation = session.startSend({ text: 'pending write', submit: true })
  1360. await Promise.resolve()
  1361. await Promise.resolve()
  1362. await session.close('pending write')
  1363. expect((await writeOperation.done).waitReason).toBe('session_exit')
  1364. // The rejection lands after close released the send; it must stay contained.
  1365. write.reject(new Error('write rejected during close'))
  1366. await Promise.resolve()
  1367. await Promise.resolve()
  1368. })
  1369. it('settles a closing send when provider termination cancels a pending inspection', async () => {
  1370. vi.useFakeTimers()
  1371. const terminal = new FakeTerminal()
  1372. const session = new LocalPtySession(terminal, config())
  1373. await initialize(session, terminal)
  1374. const inspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
  1375. terminal.inspectForeground = async () => await inspection.promise
  1376. const terminate = terminal.terminate.bind(terminal)
  1377. terminal.terminate = async () => {
  1378. inspection.reject(new Error('terminal terminated'))
  1379. await terminate()
  1380. }
  1381. const operation = session.startSend({ text: 'pending inspection', submit: true })
  1382. await Promise.resolve()
  1383. await session.close('pending inspection')
  1384. expect((await operation.done).waitReason).toBe('session_exit')
  1385. expect(terminal.writes).toEqual([])
  1386. })
  1387. it('does not let an in-flight readiness inspection outrun close', async () => {
  1388. vi.useFakeTimers()
  1389. const terminal = new FakeTerminal()
  1390. const session = new LocalPtySession(terminal, config())
  1391. await initialize(session, terminal)
  1392. const inspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
  1393. const originalInspect = terminal.inspectForeground.bind(terminal)
  1394. let inspections = 0
  1395. terminal.inspectForeground = async () => {
  1396. inspections += 1
  1397. return inspections === 1 ? await originalInspect() : await inspection.promise
  1398. }
  1399. const operation = session.startSend({ text: 'pending readiness', submit: true })
  1400. await Promise.resolve()
  1401. await Promise.resolve()
  1402. terminal.emitData('\x1b]133;D;0\x07dsh> ')
  1403. await vi.advanceTimersByTimeAsync(10)
  1404. expect(inspections).toBe(2)
  1405. const termination = Promise.withResolvers<undefined>()
  1406. terminal.terminate = async () => {
  1407. await termination.promise
  1408. terminal.emitExit(0, 15)
  1409. }
  1410. const closing = session.close('in-flight readiness')
  1411. let settled = false
  1412. void operation.done.then(() => { settled = true })
  1413. inspection.resolve({ processGroupId: 456, inputWaiting: true })
  1414. await Promise.resolve()
  1415. await Promise.resolve()
  1416. expect(settled).toBe(false)
  1417. termination.resolve(undefined)
  1418. await closing
  1419. expect((await operation.done).waitReason).toBe('session_exit')
  1420. })
  1421. })