lifecycle.spec.ts 2.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
  1. /** The provider joins unpublished terminal allocations before disposal completes. */
  2. import { PassThrough } from 'node:stream'
  3. import { Context } from '@deepseek-ai/cordis'
  4. import { describe, expect, it, vi } from 'vitest'
  5. import { SshSubprocessRuntime } from '../src/index.ts'
  6. const id = 'a7b17d9c-5ebf-40d8-9e82-3d1ddae8517b'
  7. const spec = { terminalType: 'dumb', argv: ['bash'], cwd: '/workspace', rows: 24, cols: 80, graceMs: 100 }
  8. describe('SSH terminal allocation lifecycle', () => {
  9. it.each(['prepare', 'connect', 'start'])('joins a pending %s before completing provider disposal', async (stage) => {
  10. const ctx = new Context()
  11. const entered = Promise.withResolvers<undefined>()
  12. const continuation = Promise.withResolvers<undefined>()
  13. const finished = Promise.withResolvers<{ outcome: { exitCode: number; signal: null }; spills: object }>()
  14. const socket = new PassThrough()
  15. const calls: string[] = []
  16. const gate = async (name: string) => {
  17. if (name === stage) { entered.resolve(undefined); await continuation.promise }
  18. }
  19. ctx.provide('ssh', {
  20. request: vi.fn(async (method: string) => {
  21. calls.push(method)
  22. if (method === 'process.prepare') {
  23. await gate('prepare')
  24. return { id, streams: { terminal: { path: '/tmp/terminal', capability: '0'.repeat(64) } } }
  25. }
  26. if (method === 'process.start') { await gate('start'); return { pid: 123 } }
  27. if (method === 'process.done') return finished.promise
  28. if (method === 'process.terminate') {
  29. finished.resolve({ outcome: { exitCode: 0, signal: null }, spills: {} })
  30. return null
  31. }
  32. throw new Error(`Unexpected request ${method}`)
  33. }),
  34. connectStream: async () => { await gate('connect'); return socket },
  35. dispose: vi.fn(),
  36. } as never)
  37. const fiber = ctx.plugin(SshSubprocessRuntime)
  38. await fiber
  39. const runtime = ctx.subprocess
  40. const allocation = runtime.spawnTerminal(spec)
  41. const rejected = expect(allocation).rejects.toThrow('disposed')
  42. await entered.promise
  43. let disposed = false
  44. const disposal = fiber.dispose().then(() => { disposed = true })
  45. await Promise.resolve(undefined)
  46. expect(disposed).toBe(false)
  47. continuation.resolve(undefined)
  48. await rejected
  49. await disposal
  50. expect(calls).toContain('process.terminate')
  51. if (stage !== 'start') expect(calls).not.toContain('process.start')
  52. expect(socket.destroyed || stage === 'prepare').toBe(true)
  53. await expect(runtime.spawnTerminal(spec)).rejects.toThrow('disposed')
  54. })
  55. })