session.spec.ts 66 KB

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