recovery.client.spec.ts 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736
  1. /** Reloaded tab identities and nonblocking close requests use independent lifetimes. */
  2. import { setImmediate } from 'node:timers/promises'
  3. import { afterEach, expect, it, vi } from 'vitest'
  4. import { Context } from '@deepseek-ai/cordis'
  5. import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
  6. import { RemoteError, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
  7. import { RemoteStream, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
  8. import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote'
  9. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  10. import type { WebTerminalId, WebTerminalInfo, TerminalEnvironment } from '../src/types.ts'
  11. import { TerminalView, type TerminalRemote } from '../src/client/model.ts'
  12. import { TerminalCloseRequests } from '../src/client/close-requests.ts'
  13. import { TerminalBindings } from '../src/client/bindings.ts'
  14. import * as TerminalClient from '../src/client/index.ts'
  15. const sessionId = 'session' as SessionId
  16. const info: WebTerminalInfo = { id: 'terminal' as WebTerminalId, shell: { name: 'zsh', path: '/bin/zsh', args: ['-i'] }, title: 'zsh', cwd: '/workspace', rows: 24, cols: 80, state: 'running', exitCode: null }
  17. const environment: TerminalEnvironment = { cwd: info.cwd, maxInputBytes: 1000, maxCols: 200, maxRows: 100, scrollback: 100 }
  18. const success = <T>(value: T): RemoteResult<T> => ({ ok: true, value })
  19. const failure = (message: string): RemoteResult<never> => ({ ok: false, error: new RemoteError('gateway/bad-request', message, {}) })
  20. const cleanups: (() => void | Promise<void>)[] = []
  21. afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks() })
  22. function storage() {
  23. const data = new Map<string, string>()
  24. vi.stubGlobal('localStorage', { get length() { return data.size }, key: (index: number) => [...data.keys()][index] ?? null, getItem: (key: string) => data.get(key) ?? null, setItem: (key: string, value: string) => { data.set(key, value) }, removeItem: (key: string) => { data.delete(key) } })
  25. return data
  26. }
  27. function fixture() {
  28. const remote: TerminalRemote = {
  29. retain: vi.fn<TerminalRemote['retain']>(async function* (_session, _id, signal) {
  30. yield { type: 'retained' }
  31. await new Promise<void>((resolve) => {
  32. if (signal?.aborted) resolve()
  33. else signal?.addEventListener('abort', () => { resolve() }, { once: true })
  34. })
  35. }),
  36. shells: vi.fn<TerminalRemote['shells']>(async () => success([info.shell])),
  37. environment: vi.fn<TerminalRemote['environment']>(async () => success(environment)), list: vi.fn<TerminalRemote['list']>(async () => success([])),
  38. create: vi.fn<TerminalRemote['create']>(async (_session, request) => success({ ...info, id: request.id })),
  39. close: vi.fn<TerminalRemote['close']>(async () => success(undefined)), rename: vi.fn<TerminalRemote['rename']>(async () => success(undefined)),
  40. write: vi.fn<TerminalRemote['write']>(async () => success(undefined)), resize: vi.fn<TerminalRemote['resize']>(async () => success(undefined)),
  41. follow: vi.fn<TerminalRemote['follow']>(async function* (_session, id, controllerId, signal) {
  42. yield { type: 'snapshot', sequence: 0, screen: 'retained', info: { ...info, id, controllerId } }
  43. await new Promise<void>((resolve) => {
  44. if (signal?.aborted) resolve()
  45. else signal?.addEventListener('abort', () => { resolve() }, { once: true })
  46. })
  47. }),
  48. }
  49. const gateway: Pick<ClientRemote, '$stream'> = { $stream: options => new RemoteStream({ generation: createSnapshotStore(undefined) }, options) }
  50. function view() {
  51. const model = new TerminalView(sessionId, remote, gateway, info.id)
  52. cleanups.push(() => model.dispose())
  53. return model
  54. }
  55. async function service() {
  56. const ctx = new Context()
  57. ctx.provide('remote', { ...gateway, terminal: remote } as never)
  58. ctx.provide('remote.terminal', remote)
  59. const fiber = ctx.plugin(TerminalClient)
  60. cleanups.push(async () => { await ctx.fiber.dispose() })
  61. await fiber
  62. return { service: ctx.webTerminals, dispose: () => fiber.dispose() }
  63. }
  64. return { remote, view, service }
  65. }
  66. it('starts automatically and deduplicates overlapping mounts and refreshes', async () => {
  67. const h = fixture()
  68. const model = h.view()
  69. const creation = Promise.withResolvers<RemoteResult<WebTerminalInfo>>()
  70. vi.mocked(h.remote.create).mockReturnValueOnce(creation.promise)
  71. model.mount()
  72. const loading = model.refresh()
  73. expect(model.refresh()).toBe(loading)
  74. await expect.poll(() => h.remote.create).toHaveBeenCalledOnce()
  75. const remounting = model.refresh()
  76. expect(vi.mocked(h.remote.create).mock.calls[0]?.[1]).toEqual({ id: info.id, shellPath: info.shell.path, cols: 80, rows: 24 })
  77. creation.resolve(success(info))
  78. await loading
  79. await remounting
  80. await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
  81. expect(h.remote.create).toHaveBeenCalledOnce()
  82. })
  83. it('recovers a listed process without creating another, including its title and screen', async () => {
  84. const h = fixture()
  85. vi.mocked(h.remote.list).mockResolvedValue(success([{ ...info, title: 'Build' }]))
  86. const model = h.view()
  87. model.mount()
  88. await model.refresh()
  89. expect(h.remote.create).not.toHaveBeenCalled()
  90. await expect.poll(() => model.state.getSnapshot().render?.frame).toMatchObject({ type: 'snapshot', screen: 'retained' })
  91. expect(h.remote.follow).toHaveBeenCalledWith(sessionId, info.id, expect.any(String), expect.any(AbortSignal))
  92. })
  93. it('does not recreate a recovered terminal that disappeared after the recovery list was shown', async () => {
  94. const h = fixture()
  95. const { service } = await h.service()
  96. vi.mocked(h.remote.list).mockResolvedValueOnce(success([info]))
  97. expect(await service.recover(sessionId)).toEqual([info])
  98. const model = service.view(sessionId, 'recovered', 'recovered', info.id)
  99. await model.refresh()
  100. expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', writable: false })
  101. expect(model.state.getSnapshot().issue).toBe('missingTerminal')
  102. expect(h.remote.create).not.toHaveBeenCalled()
  103. model.mount()
  104. await model.refresh()
  105. expect(h.remote.create).not.toHaveBeenCalled()
  106. })
  107. it('retries lost create acknowledgements with the saved id and closes after a refused allocation', async () => {
  108. const h = fixture()
  109. const model = h.view()
  110. vi.mocked(h.remote.create).mockResolvedValueOnce(failure('response lost'))
  111. await model.refresh()
  112. expect(model.state.getSnapshot().error).toBe('response lost')
  113. await model.refresh()
  114. expect(vi.mocked(h.remote.create).mock.calls.map(call => call[1].id)).toEqual([info.id, info.id])
  115. await model.close()
  116. expect(h.remote.close).toHaveBeenCalledWith(sessionId, info.id)
  117. })
  118. it('waits for an in-flight creation while close detaches immediately and prevents a late connection', async () => {
  119. const h = fixture()
  120. const model = h.view()
  121. const creation = Promise.withResolvers<RemoteResult<WebTerminalInfo>>()
  122. vi.mocked(h.remote.create).mockReturnValueOnce(creation.promise)
  123. const unmount = model.mount()
  124. const starting = model.refresh()
  125. await expect.poll(() => vi.mocked(h.remote.create).mock.calls.length).toBe(1)
  126. const closing = model.close()
  127. expect(model.close()).toBe(closing)
  128. unmount()
  129. expect(h.remote.close).not.toHaveBeenCalled()
  130. creation.resolve(failure('cancelled allocation'))
  131. await starting
  132. await closing
  133. expect(h.remote.close).toHaveBeenCalledWith(sessionId, info.id)
  134. expect(h.remote.follow).not.toHaveBeenCalled()
  135. })
  136. it('does not allocate after close or disposal overtakes discovery', async () => {
  137. for (const action of ['close', 'dispose'] as const) {
  138. const h = fixture()
  139. const model = h.view()
  140. const discovery = Promise.withResolvers<RemoteResult<TerminalEnvironment>>()
  141. vi.mocked(h.remote.environment).mockReturnValueOnce(discovery.promise)
  142. const loading = model.refresh()
  143. await model[action]()
  144. discovery.resolve(success(environment))
  145. await loading
  146. await model.refresh()
  147. expect(h.remote.create).not.toHaveBeenCalled()
  148. }
  149. })
  150. it('updates a restored inactive terminal title and ignores late results after disposal', async () => {
  151. const h = fixture()
  152. const model = h.view()
  153. await model.rename(' Build ')
  154. expect(h.remote.rename).toHaveBeenCalledWith(sessionId, info.id, ' Build ')
  155. expect(model.state.getSnapshot().title).toBe('Build')
  156. const rename = Promise.withResolvers<RemoteResult<void>>()
  157. vi.mocked(h.remote.rename).mockReturnValueOnce(rename.promise)
  158. const pending = model.rename('Late')
  159. await model.dispose()
  160. rename.resolve(success(undefined))
  161. await pending
  162. expect(model.state.getSnapshot().title).toBe('Build')
  163. })
  164. it('removes a view synchronously, keeps cleanup retryable, and never deletes a replacement view', async () => {
  165. storage()
  166. const h = fixture()
  167. const { service } = await h.service()
  168. const first = service.view(sessionId, 'tab', 'tab')
  169. first.mount()
  170. await first.refresh()
  171. const closing = Promise.withResolvers<RemoteResult<void>>()
  172. vi.mocked(h.remote.close).mockReturnValueOnce(closing.promise)
  173. service.close(sessionId, 'tab', 'tab')
  174. const replacement = service.view(sessionId, 'tab', 'tab')
  175. expect(replacement).not.toBe(first)
  176. expect(service.closeFailures.getSnapshot()).toEqual([])
  177. closing.resolve(failure('termination refused'))
  178. await expect.poll(() => service.closeFailures.getSnapshot().length).toBe(1)
  179. const failed = service.closeFailures.getSnapshot()[0]!
  180. service.retryClose(failed.id)
  181. service.retryClose(failed.id)
  182. await expect.poll(() => service.closeFailures.getSnapshot()).toEqual([])
  183. expect(service.view(sessionId, 'tab', 'tab')).toBe(replacement)
  184. await expect.poll(() => new TerminalCloseRequests().pending()).toEqual([])
  185. })
  186. it('closes an inactive restored tab by tab identity and retries saved close requests after reload', async () => {
  187. storage()
  188. const h = fixture()
  189. const first = await h.service()
  190. first.service.close(sessionId, 'inactive', 'inactive', 'inactive' as WebTerminalId)
  191. await expect.poll(() => vi.mocked(h.remote.close).mock.calls.length).toBe(1)
  192. expect(h.remote.close).toHaveBeenCalledWith(sessionId, 'inactive')
  193. await first.dispose()
  194. const pending = new TerminalCloseRequests()
  195. pending.save({ sessionId, id: 'second' as WebTerminalId, title: 'Second' })
  196. await h.service()
  197. await expect.poll(() => vi.mocked(h.remote.close).mock.calls.length).toBe(2)
  198. expect(h.remote.close).toHaveBeenCalledWith(sessionId, 'second')
  199. await expect.poll(() => new TerminalCloseRequests().pending()).toEqual([])
  200. })
  201. it('keeps concurrent windows close requests independent and removes only the settled request', () => {
  202. storage()
  203. const a = new TerminalCloseRequests()
  204. const b = new TerminalCloseRequests()
  205. a.save({ sessionId, id: 'a' as WebTerminalId, title: 'A' })
  206. b.save({ sessionId, id: 'b' as WebTerminalId, title: 'B' })
  207. expect(new TerminalCloseRequests().pending().map(request => request.id)).toEqual(['a', 'b'])
  208. a.remove('a' as WebTerminalId)
  209. expect(new TerminalCloseRequests().pending().map(request => request.id)).toEqual(['b'])
  210. })
  211. it('recovers Host terminals by Session while excluding held, pending-close, and already-closed identities', async () => {
  212. storage()
  213. const h = fixture()
  214. const { service } = await h.service()
  215. const held = service.view(sessionId, 'held', 'held')
  216. await held.refresh()
  217. const closing = service.view(sessionId, 'closing', 'closing')
  218. await closing.refresh()
  219. const done = service.view(sessionId, 'closed', 'closed')
  220. await done.refresh()
  221. const unheld: WebTerminalInfo = { ...info, id: 'unheld' as WebTerminalId }
  222. const pending = Promise.withResolvers<RemoteResult<void>>()
  223. vi.mocked(h.remote.close).mockReturnValueOnce(pending.promise)
  224. service.close(sessionId, 'closing', 'closing')
  225. service.close(sessionId, 'closed', 'closed')
  226. await expect.poll(() => new TerminalCloseRequests().pending().length).toBe(1)
  227. vi.mocked(h.remote.list).mockResolvedValue(success([
  228. { ...info, id: held.id }, { ...info, id: closing.id }, { ...info, id: done.id }, unheld,
  229. ]))
  230. expect(await service.recover(sessionId)).toEqual([unheld])
  231. expect(h.remote.list).toHaveBeenLastCalledWith(sessionId)
  232. const otherSession = 'other-session' as SessionId
  233. vi.mocked(h.remote.list).mockResolvedValueOnce(success([info]))
  234. expect(await service.recover(otherSession)).toEqual([info])
  235. pending.resolve(success(undefined))
  236. await expect.poll(() => new TerminalCloseRequests().pending()).toEqual([])
  237. expect(await service.recover(sessionId)).toEqual([unheld])
  238. })
  239. it('queries current held views after a slow recovery response and reports discovery errors', async () => {
  240. const h = fixture()
  241. const { service } = await h.service()
  242. const listed = Promise.withResolvers<RemoteResult<WebTerminalInfo[]>>()
  243. vi.mocked(h.remote.list).mockReturnValueOnce(listed.promise)
  244. const recovering = service.recover(sessionId)
  245. vi.mocked(h.remote.list).mockResolvedValueOnce(success([info]))
  246. const model = service.view(sessionId, 'recovered', 'recovered', info.id)
  247. await model.refresh()
  248. listed.resolve(success([info]))
  249. expect(await recovering).toEqual([])
  250. expect(service.view(sessionId, 'recovered', 'recovered', info.id)).toBe(model)
  251. expect(model.id).toBe(info.id)
  252. vi.mocked(h.remote.list).mockResolvedValueOnce(failure('Session unavailable'))
  253. await expect(service.recover(sessionId)).rejects.toThrow('Session unavailable')
  254. })
  255. it('ignores an unknown tab and retries only saved close requests', async () => {
  256. storage()
  257. const h = fixture()
  258. const { service } = await h.service()
  259. service.close(sessionId, 'unknown', 'unknown')
  260. service.retryClose('unknown' as WebTerminalId)
  261. expect(h.remote.close).not.toHaveBeenCalled()
  262. expect(new TerminalCloseRequests().pending()).toEqual([])
  263. })
  264. it('retains an inactive close failure with its tab title until retry succeeds', async () => {
  265. const data = storage()
  266. const h = fixture()
  267. const { service } = await h.service()
  268. vi.mocked(h.remote.close).mockResolvedValueOnce(failure('Host refused cleanup'))
  269. service.close(sessionId, 'Build', 'Build', info.id)
  270. expect(data.has(`dsh.terminal.close.v1.${info.id}`)).toBe(true)
  271. await expect.poll(() => service.closeFailures.getSnapshot()).toEqual([{ id: info.id, title: 'Build', message: 'Host refused cleanup' }])
  272. vi.mocked(h.remote.list).mockResolvedValueOnce(success([info]))
  273. expect(await service.recover(sessionId)).toEqual([])
  274. const pending = Promise.withResolvers<RemoteResult<void>>()
  275. vi.mocked(h.remote.close).mockReturnValueOnce(pending.promise)
  276. service.retryClose(info.id)
  277. service.retryClose(info.id)
  278. expect(service.closeFailures.getSnapshot()).toEqual([])
  279. expect(h.remote.close).toHaveBeenCalledTimes(2)
  280. pending.resolve(success(undefined))
  281. await expect.poll(() => data.size).toBe(0)
  282. })
  283. it('preserves a close failure from a non-Error rejection', async () => {
  284. storage()
  285. const h = fixture()
  286. const { service } = await h.service()
  287. vi.mocked(h.remote.close).mockRejectedValueOnce('carrier closed')
  288. service.close(sessionId, 'Build', 'Build', info.id)
  289. await expect.poll(() => service.closeFailures.getSnapshot()).toEqual([{ id: info.id, title: 'Build', message: 'carrier closed' }])
  290. })
  291. it('waits for pending cleanup during service disposal without publishing a late failure', async () => {
  292. storage()
  293. const h = fixture()
  294. const { service, dispose } = await h.service()
  295. const pending = Promise.withResolvers<RemoteResult<void>>()
  296. vi.mocked(h.remote.close).mockReturnValueOnce(pending.promise)
  297. service.close(sessionId, 'Build', 'Build', info.id)
  298. let finished = false
  299. const disposing = dispose().then(() => { finished = true })
  300. expect(finished).toBe(false)
  301. pending.resolve(failure('transport is stopping'))
  302. await disposing
  303. expect(service.closeFailures.getSnapshot()).toEqual([])
  304. expect(new TerminalCloseRequests().pending()).toEqual([{ sessionId, id: info.id, title: 'Build' }])
  305. service.retryClose(info.id)
  306. expect(h.remote.close).toHaveBeenCalledOnce()
  307. })
  308. it('persists occurrence identities before allocation and removes them on close without saving process output', async () => {
  309. const data = storage()
  310. const h = fixture()
  311. const { service } = await h.service()
  312. const model = service.view(sessionId, 'new-tab', 'new-tab')
  313. const key = 'dsh.terminal.binding.v1.' + JSON.stringify([sessionId, 'new-tab'])
  314. expect(JSON.parse(data.get(key)!)).toBe(model.id)
  315. await model.refresh()
  316. expect(model.id).toMatch(/^[0-9a-f-]{36}$/)
  317. expect([...data.keys()]).toEqual([key, 'dsh.terminal.shell'])
  318. const pending = Promise.withResolvers<RemoteResult<void>>()
  319. vi.mocked(h.remote.close).mockReturnValueOnce(pending.promise)
  320. service.close(sessionId, 'new-tab', 'new-tab')
  321. const request = { sessionId, id: model.id, title: info.title }
  322. expect(data.has(key)).toBe(false)
  323. expect(data.get(`dsh.terminal.close.v1.${model.id}`)).toBe(JSON.stringify(request))
  324. pending.resolve(success(undefined))
  325. await expect.poll(() => [...data.keys()]).toEqual(['dsh.terminal.shell'])
  326. })
  327. it('restores the same terminal in the same occurrence after reload without opening a recovery duplicate', async () => {
  328. storage()
  329. const h = fixture()
  330. const first = await h.service()
  331. const original = first.service.view(sessionId, 'tab', 'tab')
  332. await original.refresh()
  333. await first.dispose()
  334. vi.mocked(h.remote.list).mockResolvedValue(success([{ ...info, id: original.id }]))
  335. const second = await h.service()
  336. const restored = second.service.view(sessionId, 'tab', 'tab')
  337. expect(await second.service.recover(sessionId)).toEqual([])
  338. restored.mount()
  339. await restored.refresh()
  340. expect(restored.id).toBe(original.id)
  341. expect(h.remote.create).toHaveBeenCalledOnce()
  342. await expect.poll(() => restored.state.getSnapshot().render?.frame).toMatchObject({ type: 'snapshot', screen: 'retained' })
  343. })
  344. it('offers retained processes when their saved occurrence is absent from the restored layout', async () => {
  345. storage()
  346. const h = fixture()
  347. const first = await h.service()
  348. const original = first.service.view(sessionId, 'lost-tab', 'lost-tab')
  349. await original.refresh()
  350. await first.dispose()
  351. const retained = { ...info, id: original.id }
  352. vi.mocked(h.remote.list).mockResolvedValue(success([retained]))
  353. const second = await h.service()
  354. expect(await second.service.recover(sessionId)).toEqual([retained])
  355. })
  356. it('keeps identical occurrence keys in different Sessions independent across reload', async () => {
  357. storage()
  358. const h = fixture()
  359. const first = await h.service()
  360. const a = first.service.view(sessionId, 'tab', 'tab')
  361. const other = 'other' as SessionId
  362. const b = first.service.view(other, 'tab', 'tab')
  363. await Promise.all([a.refresh(), b.refresh()])
  364. expect(a.id).not.toBe(b.id)
  365. await first.dispose()
  366. const second = await h.service()
  367. expect(second.service.view(sessionId, 'tab', 'tab').id).toBe(a.id)
  368. expect(second.service.view(other, 'tab', 'tab').id).toBe(b.id)
  369. })
  370. it('closes a saved inactive occurrence without mounting a view after reload', async () => {
  371. storage()
  372. const h = fixture()
  373. const first = await h.service()
  374. const original = first.service.view(sessionId, 'tab', 'tab')
  375. await original.refresh()
  376. await first.dispose()
  377. const second = await h.service()
  378. second.service.close(sessionId, 'tab', 'tab')
  379. await expect.poll(() => h.remote.close).toHaveBeenCalledWith(sessionId, original.id)
  380. expect(h.remote.create).toHaveBeenCalledOnce()
  381. })
  382. it('reports a missing saved terminal after reload and never starts a replacement shell', async () => {
  383. storage()
  384. const h = fixture()
  385. const first = await h.service()
  386. const original = first.service.view(sessionId, 'tab', 'tab')
  387. await original.refresh()
  388. await first.dispose()
  389. const second = await h.service()
  390. const restored = second.service.view(sessionId, 'tab', 'tab')
  391. await restored.refresh()
  392. expect(restored.state.getSnapshot()).toMatchObject({ phase: 'failed', issue: 'missingTerminal' })
  393. await restored.refresh()
  394. expect(h.remote.create).toHaveBeenCalledOnce()
  395. })
  396. it.each(['{broken', 'null', '{}', '[{}]', '{"sessionId":"s","id":"bad/id","title":"x"}', '{"sessionId":"s","id":"different","title":"x"}'])('discards malformed saved cleanup: %s', (raw) => {
  397. const data = storage()
  398. data.set('dsh.terminal.close.v1.terminal', raw)
  399. const error = vi.spyOn(console, 'error').mockImplementation(() => {})
  400. expect(new TerminalCloseRequests().pending()).toEqual([])
  401. expect(error).toHaveBeenCalledOnce()
  402. })
  403. it('skips unrelated storage and cleanup keys removed during enumeration', () => {
  404. const getItem = vi.fn(() => null)
  405. vi.stubGlobal('localStorage', {
  406. length: 3,
  407. key: (index: number) => ['unrelated', 'dsh.terminal.close.v1.gone', null][index],
  408. getItem,
  409. })
  410. expect(new TerminalCloseRequests().pending()).toEqual([])
  411. expect(getItem).toHaveBeenCalledExactlyOnceWith('dsh.terminal.close.v1.gone')
  412. })
  413. it('keeps close requests usable without browser storage', () => {
  414. vi.stubGlobal('localStorage', undefined)
  415. const requests = new TerminalCloseRequests()
  416. requests.save({ sessionId, id: info.id, title: 'Build' })
  417. expect(requests.pending()).toEqual([{ sessionId, id: info.id, title: 'Build' }])
  418. requests.remove(info.id)
  419. expect(requests.pending()).toEqual([])
  420. })
  421. it('keeps cleanup usable in memory when storage access itself is denied', () => {
  422. const error = vi.spyOn(console, 'error').mockImplementation(() => {})
  423. vi.stubGlobal('localStorage', undefined)
  424. Object.defineProperty(globalThis, 'localStorage', { configurable: true, get() { throw new Error('storage denied') } })
  425. const requests = new TerminalCloseRequests()
  426. requests.save({ sessionId, id: info.id, title: 'Build' })
  427. expect(requests.pending()).toHaveLength(1)
  428. requests.remove(info.id)
  429. expect(requests.pending()).toEqual([])
  430. expect(error).toHaveBeenCalledTimes(3)
  431. })
  432. it.each(['saved', 'view'] as const)('clears a %s close request after the Host confirms that its Session does not exist', async (source) => {
  433. const data = storage()
  434. const h = fixture()
  435. const requests = new TerminalCloseRequests()
  436. if (source === 'saved') requests.save({ sessionId, id: info.id, title: 'Build' })
  437. vi.mocked(h.remote.close).mockResolvedValue({ ok: false, error: new RemoteError('session/not-found', 'Deleted Session', { sessionId }) })
  438. const { service, dispose } = await h.service()
  439. if (source === 'view') {
  440. const view = service.view(sessionId, 'tab', 'tab')
  441. await view.refresh()
  442. service.close(sessionId, 'tab', 'tab')
  443. }
  444. await expect.poll(() => vi.mocked(h.remote.close).mock.calls.length).toBe(1)
  445. await dispose()
  446. expect([...data.entries()]).toEqual(source === 'view'
  447. ? [['dsh.terminal.shell', info.shell.path]] : [])
  448. expect(service.closeFailures.getSnapshot()).toEqual([])
  449. expect(new TerminalCloseRequests().pending()).toEqual([])
  450. await h.service()
  451. expect(h.remote.close).toHaveBeenCalledOnce()
  452. })
  453. it('waits for both active and detached stream finalizers during plugin disposal without closing Host processes', async () => {
  454. const h = fixture()
  455. const { service, dispose } = await h.service()
  456. const started = [Promise.withResolvers<undefined>(), Promise.withResolvers<undefined>()]
  457. const release = [Promise.withResolvers<undefined>(), Promise.withResolvers<undefined>()]
  458. const finished = [Promise.withResolvers<undefined>(), Promise.withResolvers<undefined>()]
  459. cleanups.push(() => { for (const barrier of release) barrier.resolve(undefined) })
  460. let index = 0
  461. vi.mocked(h.remote.follow).mockImplementation(async function* (_session, id, controllerId, signal) {
  462. const current = index++
  463. try {
  464. yield { type: 'snapshot', sequence: 0, screen: 'screen', info: { ...info, id, controllerId } }
  465. await new Promise<void>((resolve) => {
  466. if (signal?.aborted) resolve()
  467. else signal?.addEventListener('abort', () => { resolve() }, { once: true })
  468. })
  469. } finally {
  470. started[current]!.resolve(undefined)
  471. await release[current]!.promise
  472. finished[current]!.resolve(undefined)
  473. }
  474. })
  475. const model = service.view(sessionId, 'tab', 'tab')
  476. model.mount()
  477. await model.refresh()
  478. await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
  479. model.connect()
  480. await started[0]!.promise
  481. await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
  482. let disposed = false
  483. const disposing = dispose().then(() => { disposed = true })
  484. await started[1]!.promise
  485. release[1]!.resolve(undefined)
  486. await finished[1]!.promise
  487. // Drain runnable disposal continuations; only the held old finalizer may keep teardown pending.
  488. await setImmediate()
  489. expect(disposed).toBe(false)
  490. release[0]!.resolve(undefined)
  491. await disposing
  492. expect(disposed).toBe(true)
  493. expect(h.remote.close).not.toHaveBeenCalled()
  494. })
  495. it('discovers menu choices without creating a process and remembers a choice before opening its tab', async () => {
  496. const data = storage()
  497. const h = fixture()
  498. const alternate = { name: 'bash', path: '/bin/bash', args: ['-i'] }
  499. vi.mocked(h.remote.shells).mockResolvedValue(success([info.shell, alternate]))
  500. const { service } = await h.service()
  501. expect(await service.launchShells(sessionId, new AbortController().signal)).toEqual({
  502. shells: [info.shell, alternate], selectedShell: info.shell.path,
  503. })
  504. service.selectShell(alternate.path)
  505. expect(data.get('dsh.terminal.shell')).toBe(alternate.path)
  506. expect(h.remote.create).not.toHaveBeenCalled()
  507. expect((await service.launchShells(sessionId, new AbortController().signal)).selectedShell).toBe(alternate.path)
  508. const model = service.view(sessionId, 'chosen', 'chosen', undefined, alternate.path)
  509. await model.refresh()
  510. expect(h.remote.create).toHaveBeenLastCalledWith(
  511. sessionId, expect.objectContaining({ shellPath: alternate.path }), expect.any(AbortSignal),
  512. )
  513. expect(h.remote.shells).toHaveBeenCalledTimes(2)
  514. await h.view().refresh()
  515. expect(h.remote.create).toHaveBeenLastCalledWith(
  516. sessionId, expect.objectContaining({ shellPath: alternate.path }), expect.any(AbortSignal),
  517. )
  518. vi.mocked(h.remote.shells).mockResolvedValue(success([info.shell]))
  519. await h.view().refresh()
  520. expect(h.remote.create).toHaveBeenLastCalledWith(
  521. sessionId, expect.objectContaining({ shellPath: info.shell.path }), expect.any(AbortSignal),
  522. )
  523. vi.mocked(h.remote.shells).mockResolvedValueOnce(failure('host offline'))
  524. await expect(service.launchShells(sessionId, new AbortController().signal)).rejects.toThrow('host offline')
  525. vi.mocked(h.remote.shells).mockResolvedValue(success([]))
  526. expect((await service.launchShells(sessionId, new AbortController().signal)).selectedShell).toBeUndefined()
  527. await h.view().refresh()
  528. expect(vi.mocked(h.remote.create).mock.calls.at(-1)?.[1]).not.toHaveProperty('shellPath')
  529. })
  530. it('retains the last selection across a failed automatic launch', async () => {
  531. const data = storage()
  532. const h = fixture()
  533. vi.mocked(h.remote.create).mockResolvedValueOnce(failure('shell disappeared'))
  534. const model = h.view()
  535. await model.refresh()
  536. expect(data.get('dsh.terminal.shell')).toBe(info.shell.path)
  537. expect(model.state.getSnapshot().error).toBe('shell disappeared')
  538. await model.refresh()
  539. expect(h.remote.create).toHaveBeenCalledTimes(2)
  540. await model.close()
  541. await model.refresh()
  542. expect(h.remote.create).toHaveBeenCalledTimes(2)
  543. })
  544. it('keeps launch usable when browser storage is denied and stops late shell discovery after close', async () => {
  545. vi.stubGlobal('localStorage', undefined)
  546. Object.defineProperty(globalThis, 'localStorage', { configurable: true, get() { throw new Error('denied') } })
  547. const h = fixture()
  548. const model = h.view()
  549. await model.refresh()
  550. expect(model.state.getSnapshot().info).toBeDefined()
  551. const delayed = h.view()
  552. const shells = Promise.withResolvers<Awaited<ReturnType<TerminalRemote['shells']>>>()
  553. vi.mocked(h.remote.shells).mockReturnValueOnce(shells.promise)
  554. const loading = delayed.refresh()
  555. await expect.poll(() => h.remote.shells).toHaveBeenCalledTimes(2)
  556. await delayed.close()
  557. shells.resolve(success([info.shell]))
  558. await loading
  559. expect(delayed.state.getSnapshot().phase).toBe('closed')
  560. expect(h.remote.create).toHaveBeenCalledOnce()
  561. })
  562. it('retains only open saved occurrences across inactive Sessions and deduplicates their Host identities', async () => {
  563. storage()
  564. const otherSession = 'dormant-session' as SessionId
  565. const otherId = 'other-terminal' as WebTerminalId
  566. const bindings = new TerminalBindings()
  567. bindings.set(sessionId, 'a', info.id)
  568. bindings.set(sessionId, 'duplicate', info.id)
  569. bindings.set(sessionId, 'stale', 'orphan' as WebTerminalId)
  570. bindings.set(otherSession, 'b', otherId)
  571. const h = fixture()
  572. const { service } = await h.service()
  573. service.retainTabs([{ sessionId, tabId: 'a', contentId: 'a' }, { sessionId, tabId: 'duplicate', contentId: 'duplicate' }, { sessionId: otherSession, tabId: 'b', contentId: 'b' }])
  574. await expect.poll(() => h.remote.retain).toHaveBeenCalledTimes(2)
  575. expect(h.remote.environment).not.toHaveBeenCalled()
  576. expect(h.remote.create).not.toHaveBeenCalled()
  577. expect(h.remote.list).not.toHaveBeenCalled()
  578. expect(h.remote.follow).not.toHaveBeenCalled()
  579. const calls = vi.mocked(h.remote.retain).mock.calls
  580. service.retainTabs([{ sessionId: otherSession, tabId: 'b', contentId: 'b' }])
  581. await expect.poll(() => calls.find(call => call[0] === sessionId)?.[2]?.aborted).toBe(true)
  582. expect(calls.find(call => call[0] === otherSession)?.[2]?.aborted).toBe(false)
  583. service.retainTabs([])
  584. await expect.poll(() => calls.every(call => call[2]?.aborted)).toBe(true)
  585. })
  586. it('waits for the window hold acknowledgement before restoring an output attachment', async () => {
  587. storage()
  588. new TerminalBindings().set(sessionId, 'restored', info.id)
  589. const h = fixture()
  590. vi.mocked(h.remote.list).mockResolvedValue(success([info]))
  591. const acknowledge = Promise.withResolvers<undefined>()
  592. vi.mocked(h.remote.retain).mockImplementation(async function* (_session, _id, signal) {
  593. await acknowledge.promise
  594. yield { type: 'retained' }
  595. await new Promise<void>((resolve) => {
  596. if (signal?.aborted) resolve()
  597. else signal?.addEventListener('abort', () => { resolve() }, { once: true })
  598. })
  599. })
  600. const { service } = await h.service()
  601. service.retainTabs([{ sessionId, tabId: 'restored', contentId: 'restored' }])
  602. const model = service.view(sessionId, 'restored', 'restored')
  603. model.mount()
  604. await model.refresh()
  605. expect(h.remote.follow).not.toHaveBeenCalled()
  606. acknowledge.resolve(undefined)
  607. await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
  608. expect(h.remote.retain).toHaveBeenCalledOnce()
  609. expect(h.remote.create).not.toHaveBeenCalled()
  610. })
  611. it('shows a missing terminal when retention loses the race to Host cleanup, without allocating a replacement', async () => {
  612. storage()
  613. new TerminalBindings().set(sessionId, 'restored', info.id)
  614. const h = fixture()
  615. vi.mocked(h.remote.list).mockResolvedValue(success([info]))
  616. vi.mocked(h.remote.retain).mockImplementation(() => { throw new RemoteError('terminal/unavailable', 'gone', {}) })
  617. const { service } = await h.service()
  618. service.retainTabs([{ sessionId, tabId: 'restored', contentId: 'restored' }])
  619. const model = service.view(sessionId, 'restored', 'restored')
  620. model.mount()
  621. await expect.poll(() => model.state.getSnapshot().issue).toBe('missingTerminal')
  622. expect(h.remote.follow).not.toHaveBeenCalled()
  623. expect(h.remote.create).not.toHaveBeenCalled()
  624. })
  625. it('ignores late inventory after disposal and excludes missing bindings and saved close requests', async () => {
  626. storage()
  627. new TerminalBindings().set(sessionId, 'closing', info.id)
  628. new TerminalCloseRequests().save({ sessionId, id: info.id, title: 'Closing' })
  629. const h = fixture()
  630. const { service, dispose } = await h.service()
  631. service.retainTabs([{ sessionId, tabId: 'missing', contentId: 'missing' }, { sessionId, tabId: 'closing', contentId: 'closing' }])
  632. expect(h.remote.retain).not.toHaveBeenCalled()
  633. await dispose()
  634. service.retainTabs([{ sessionId, tabId: 'closing', contentId: 'closing' }])
  635. expect(h.remote.retain).not.toHaveBeenCalled()
  636. })
  637. it('keeps other holds usable when releasing one transport fails', async () => {
  638. storage()
  639. new TerminalBindings().set(sessionId, 'a', info.id)
  640. const h = fixture()
  641. const { service } = await h.service()
  642. service.retainTabs([{ sessionId, tabId: 'a', contentId: 'a' }])
  643. await expect.poll(() => h.remote.retain).toHaveBeenCalledOnce()
  644. const { TerminalWindowHold } = await import('../src/client/retention.ts')
  645. // oxlint-disable-next-line typescript/unbound-method -- Preserve the real disposer while injecting one failure after it settles.
  646. const original = TerminalWindowHold.prototype.dispose
  647. const failure = vi.spyOn(TerminalWindowHold.prototype, 'dispose')
  648. failure.mockImplementationOnce(async function (this: InstanceType<typeof TerminalWindowHold>) {
  649. await original.call(this)
  650. throw new Error('transport close failed')
  651. })
  652. service.retainTabs([])
  653. await expect.poll(() => failure).toHaveBeenCalledOnce()
  654. await setImmediate()
  655. failure.mockRestore()
  656. service.retainTabs([{ sessionId, tabId: 'a', contentId: 'a' }])
  657. await expect.poll(() => h.remote.retain).toHaveBeenCalledTimes(2)
  658. })
  659. it('ignores a delayed retention failure after the restored view was disposed', async () => {
  660. const h = fixture()
  661. vi.mocked(h.remote.list).mockResolvedValue(success([info]))
  662. const retained = Promise.withResolvers<undefined>()
  663. const gateway: Pick<ClientRemote, '$stream'> = { $stream: options => new RemoteStream({ generation: createSnapshotStore(undefined) }, options) }
  664. const model = new TerminalView(sessionId, h.remote, gateway, info.id, false, undefined, () => retained.promise)
  665. cleanups.push(() => model.dispose())
  666. await model.refresh()
  667. await model.dispose()
  668. const snapshot = model.state.getSnapshot()
  669. retained.reject(new Error('late hold rejection'))
  670. await setImmediate()
  671. expect(model.state.getSnapshot()).toBe(snapshot)
  672. })
  673. it('keeps new terminals with colliding layout-local tab ids independent across shared-storage windows', async () => {
  674. storage()
  675. const h = fixture()
  676. const first = await h.service()
  677. const second = await h.service()
  678. const a = first.service.view(sessionId, 'tab2', 'sidebar://terminal/a')
  679. await a.refresh()
  680. const b = second.service.view(sessionId, 'tab2', 'sidebar://terminal/b')
  681. await b.refresh()
  682. expect(a.id).not.toBe(b.id)
  683. expect(h.remote.create).toHaveBeenCalledTimes(2)
  684. first.service.close(sessionId, 'tab2', 'sidebar://terminal/a')
  685. await expect.poll(() => new TerminalCloseRequests().pending()).toEqual([])
  686. expect(h.remote.close).toHaveBeenCalledWith(sessionId, a.id)
  687. expect(h.remote.close).not.toHaveBeenCalledWith(sessionId, b.id)
  688. await second.dispose()
  689. vi.mocked(h.remote.list).mockResolvedValue(success([{ ...info, id: b.id }]))
  690. const reloaded = await h.service()
  691. const restored = reloaded.service.view(sessionId, 'tab2', 'sidebar://terminal/b')
  692. await restored.refresh()
  693. expect(restored.id).toBe(b.id)
  694. expect(h.remote.create).toHaveBeenCalledTimes(2)
  695. })