terminal-behavior.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. /** Published terminal operations and cleanup failures over the remote provider seam. */
  2. import { duplexPair } from 'node:stream'
  3. import { once } from 'node:events'
  4. import { Context } from '@deepseek-ai/cordis'
  5. import type { SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess'
  6. import { describe, expect, it, onTestFinished, vi } from 'vitest'
  7. import { z } from 'zod'
  8. import { SshSubprocessRuntime } from '../src/index.ts'
  9. const id = 'aa910b47-e7d7-467b-8421-3331569dd02b'
  10. const spec: SubprocessTerminalSpawnSpec = { argv: ['bash'], cwd: '/remote/workspace', terminalType: 'dumb', rows: 24, cols: 80, graceMs: 100 }
  11. const completion = { outcome: { exitCode: 0, signal: null }, spills: {}, collected: {} }
  12. async function setup(options: {
  13. connectFailure?: Error
  14. terminateFailure?: Error | undefined
  15. pauseConnect?: boolean
  16. missingEndpoint?: boolean
  17. } = {}) {
  18. const ctx = new Context()
  19. const cleanupErrors: unknown[] = []
  20. ctx.logger.error = ((error: unknown) => { cleanupErrors.push(error) }) as typeof ctx.logger.error
  21. const [host, remote] = duplexPair({ allowHalfOpen: true })
  22. host.on('error', () => {})
  23. remote.on('error', () => {})
  24. const entered = Promise.withResolvers<undefined>()
  25. const allocationAborted = Promise.withResolvers<undefined>()
  26. const release = Promise.withResolvers<undefined>()
  27. const finished = Promise.withResolvers<typeof completion>()
  28. const calls: { method: string; params: unknown; longRunning?: boolean }[] = []
  29. let foreground: { processGroupId: number; inputWaiting: boolean } | null = null
  30. const connection = {
  31. dispose: vi.fn(() => { host.destroy(); remote.destroy(); return Promise.resolve() }),
  32. request: async <T>(method: string, params: unknown, schema: z.ZodType<T>, _signal?: AbortSignal, longRunning?: boolean): Promise<T> => {
  33. calls.push({ method, params, ...(longRunning === undefined ? {} : { longRunning }) })
  34. let value: unknown
  35. if (method === 'process.prepare') {
  36. value = { id, streams: options.missingEndpoint ? {} : { terminal: { path: '/tmp/terminal', capability: 'a'.repeat(64) } } }
  37. } else if (method === 'process.start') value = { pid: 321 }
  38. else if (method === 'process.done') value = await finished.promise
  39. else if (method === 'process.terminate') {
  40. if (options.terminateFailure !== undefined) throw options.terminateFailure
  41. finished.resolve(completion)
  42. value = null
  43. } else if (method === 'terminal.write' || method === 'terminal.resize') value = null
  44. else if (method === 'terminal.inspect') value = foreground
  45. else if (method === 'terminal.signal') value = 321
  46. else throw new Error(`Unexpected terminal request ${method}`)
  47. return schema.parse(value)
  48. },
  49. connectStream: vi.fn(async (_endpoint: unknown, signal?: AbortSignal) => {
  50. entered.resolve(undefined)
  51. signal?.addEventListener('abort', () => { allocationAborted.resolve(undefined) }, { once: true })
  52. if (options.pauseConnect) await release.promise
  53. if (options.connectFailure !== undefined) throw options.connectFailure
  54. return host
  55. }),
  56. }
  57. ctx.provide('ssh', connection as never)
  58. const fiber = await ctx.plugin(SshSubprocessRuntime)
  59. let closing: Promise<void> | undefined
  60. const dispose = (): Promise<void> => closing ??= fiber.dispose()
  61. const close = (): Promise<void> => {
  62. release.resolve(undefined)
  63. finished.resolve(completion)
  64. host.destroy()
  65. remote.destroy()
  66. return dispose()
  67. }
  68. onTestFinished(async () => {
  69. host.destroy()
  70. remote.destroy()
  71. if (closing === undefined) await close()
  72. })
  73. return {
  74. runtime: ctx.subprocess, connection, calls, host, remote, finished, close, dispose, cleanupErrors,
  75. entered: entered.promise, release: () => { release.resolve(undefined) },
  76. allocationAborted: allocationAborted.promise,
  77. setForeground: (value: typeof foreground) => { foreground = value },
  78. }
  79. }
  80. describe('SSH terminal behavior', () => {
  81. it('publishes output and forwards terminal operations with remote process observations', async () => {
  82. const test = await setup()
  83. const handle = await test.runtime.spawnTerminal({ ...spec, env: { KEEP: 'value' } })
  84. expect(handle.pid).toBe(321)
  85. const data = once(handle.output, 'data')
  86. test.remote.write('terminal output')
  87. expect(String((await data)[0])).toBe('terminal output')
  88. await handle.write('input\n')
  89. await handle.resize(120, 40)
  90. expect(test.calls.find(call => call.method === 'terminal.resize')?.params).toEqual({ id, cols: 120, rows: 40 })
  91. expect(await handle.inspectForeground()).toBeUndefined()
  92. test.setForeground({ processGroupId: 321, inputWaiting: true })
  93. expect(await handle.inspectForeground()).toEqual({ processGroupId: 321, inputWaiting: true })
  94. expect(await handle.signalForeground('SIGINT')).toBe(321)
  95. expect(test.calls.find(call => call.method === 'process.prepare')?.params).toEqual({
  96. argv: ['bash'], cwd: spec.cwd, env: { KEEP: 'value' }, graceMs: 100,
  97. terminal: { terminalType: 'dumb', rows: 24, cols: 80 },
  98. })
  99. expect(test.calls.find(call => call.method === 'terminal.write')?.params).toEqual({ id, value: 'input\n' })
  100. expect(test.calls.find(call => call.method === 'terminal.signal')?.params).toEqual({ id, value: 'SIGINT' })
  101. test.finished.resolve(completion)
  102. expect(await handle.done).toEqual({ exitCode: 0, signal: null })
  103. await Promise.all([handle.terminate(), handle.terminate()])
  104. expect(test.calls.filter(call => call.method === 'process.terminate')).toHaveLength(1)
  105. expect(test.calls.find(call => call.method === 'process.terminate')?.longRunning).toBe(true)
  106. expect(test.host.destroyed).toBe(true)
  107. expect(handle.output.destroyed).toBe(true)
  108. await test.close()
  109. expect(test.calls.filter(call => call.method === 'process.terminate')).toHaveLength(1)
  110. })
  111. it('retries termination after a remote cleanup failure without discarding the live handle', async () => {
  112. const failure = new Error('range observation failed')
  113. const options = { terminateFailure: failure as Error | undefined }
  114. const test = await setup(options)
  115. const handle = await test.runtime.spawnTerminal(spec)
  116. await expect(handle.terminate()).rejects.toBe(failure)
  117. expect(handle.output.destroyed).toBe(false)
  118. expect(test.connection.dispose).not.toHaveBeenCalled()
  119. options.terminateFailure = undefined
  120. await handle.terminate()
  121. expect(await handle.done).toEqual(completion.outcome)
  122. expect(test.calls.filter(call => call.method === 'process.terminate')).toHaveLength(2)
  123. expect(handle.output.destroyed).toBe(true)
  124. })
  125. it('keeps termination available after the direct-result observation fails', async () => {
  126. const test = await setup()
  127. const handle = await test.runtime.spawnTerminal(spec)
  128. const failure = new Error('direct result channel closed')
  129. const rejected = expect(handle.done).rejects.toBe(failure)
  130. test.finished.reject(failure)
  131. await rejected
  132. await handle.terminate()
  133. expect(test.calls.filter(call => call.method === 'process.terminate')).toHaveLength(1)
  134. expect(handle.output.destroyed).toBe(true)
  135. })
  136. it('terminates a published terminal when its caller aborts', async () => {
  137. const test = await setup()
  138. const controller = new AbortController()
  139. const handle = await test.runtime.spawnTerminal({ ...spec, signal: controller.signal })
  140. controller.abort(new Error('caller stopped'))
  141. await handle.terminate()
  142. expect(test.calls.filter(call => call.method === 'process.terminate')).toHaveLength(1)
  143. expect(handle.output.destroyed).toBe(true)
  144. expect(await handle.done).toEqual(completion.outcome)
  145. })
  146. it('releases the SSH connection when abort cannot confirm remote termination', async () => {
  147. const options = { terminateFailure: new Error('termination lost') as Error | undefined }
  148. const test = await setup(options)
  149. test.connection.dispose.mockRejectedValueOnce(new Error('transport already lost'))
  150. const controller = new AbortController()
  151. const handle = await test.runtime.spawnTerminal({ ...spec, signal: controller.signal })
  152. controller.abort()
  153. await expect.poll(() => test.connection.dispose.mock.calls.length).toBe(1)
  154. options.terminateFailure = undefined
  155. await handle.terminate()
  156. expect(handle.output.destroyed).toBe(true)
  157. })
  158. it('rejects a terminal reservation without its required stream and confirms cleanup', async () => {
  159. const test = await setup({ missingEndpoint: true })
  160. await expect(test.runtime.spawnTerminal(spec)).rejects.toBeInstanceOf(z.ZodError)
  161. expect(test.connection.connectStream).not.toHaveBeenCalled()
  162. expect(test.calls.map(call => call.method)).toEqual(['process.prepare', 'process.terminate'])
  163. })
  164. it('reports both unpublished allocation and cleanup failures during provider disposal', async () => {
  165. const connectFailure = new Error('stream connection failed')
  166. const terminateFailure = new Error('remote cleanup failed')
  167. const test = await setup({ connectFailure, terminateFailure, pauseConnect: true })
  168. test.connection.dispose.mockRejectedValueOnce(new Error('transport already lost'))
  169. const allocation = test.runtime.spawnTerminal(spec)
  170. const failed = expect(allocation).rejects.toMatchObject({ errors: [connectFailure, terminateFailure] })
  171. await test.entered
  172. const disposal = test.dispose()
  173. await test.allocationAborted
  174. test.release()
  175. await Promise.all([failed, disposal])
  176. expect(test.cleanupErrors).toMatchObject([{
  177. message: 'SSH process cleanup could not be confirmed',
  178. errors: [expect.objectContaining({ errors: [connectFailure, terminateFailure] })],
  179. }])
  180. expect(test.connection.dispose).toHaveBeenCalledOnce()
  181. expect(test.calls.some(call => call.method === 'process.start')).toBe(false)
  182. })
  183. it('refuses admission after caller cancellation or provider disposal', async () => {
  184. const test = await setup()
  185. const stopped = AbortSignal.abort(new Error('cancelled before admission'))
  186. await expect(test.runtime.spawnTerminal({ ...spec, signal: stopped })).rejects.toThrow('cancelled before admission')
  187. expect(test.calls).toEqual([])
  188. await test.close()
  189. await expect(test.runtime.spawnTerminal(spec)).rejects.toThrow('disposed')
  190. expect(test.calls).toEqual([])
  191. })
  192. it('terminates a published terminal when the provider is disposed', async () => {
  193. const test = await setup()
  194. const handle = await test.runtime.spawnTerminal(spec)
  195. await test.close()
  196. expect(test.calls.filter(call => call.method === 'process.terminate')).toHaveLength(1)
  197. expect(handle.output.destroyed).toBe(true)
  198. expect(await handle.done).toEqual(completion.outcome)
  199. })
  200. })