process-behavior.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. /** Remote process transport, output observations, and managed cleanup through the public provider. */
  2. import { duplexPair, type Duplex } from 'node:stream'
  3. import { Context } from '@deepseek-ai/cordis'
  4. import { describe, expect, it, onTestFinished, vi } from 'vitest'
  5. import { z } from 'zod'
  6. import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
  7. import { SshRpcPeer } from '../../ssh/src/protocol.ts'
  8. import { outputSnapshotFrameLimit } from '../../ssh/src/schemas.ts'
  9. import { SshSubprocessRuntime } from '../src/index.ts'
  10. const id = 'fd897b7b-1b7e-4cd0-9b8d-06b354062d91'
  11. const spec: SubprocessSpawnSpec = {
  12. argv: ['/usr/bin/node', '-e', ''], cwd: '/workspace', graceMs: 5,
  13. stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
  14. }
  15. interface FinalOutput {
  16. outcome: { exitCode: number | null; signal: string | null }
  17. spills: { stdout?: string; stderr?: string }
  18. collected: { stdout?: { tail: string; totalBytes: number }; stderr?: { tail: string; totalBytes: number } }
  19. }
  20. const emptyResult: FinalOutput = { outcome: { exitCode: 0, signal: null }, spills: {}, collected: {} }
  21. async function setup(options: { pause?: 'prepare' | 'connect' | 'start'; failPrepare?: Error; failStart?: unknown; failTerminate?: Error; wait?: boolean } = {}) {
  22. const ctx = new Context()
  23. const disposalErrors: unknown[] = []
  24. ctx.logger.error = ((error: unknown) => { disposalErrors.push(error) }) as typeof ctx.logger.error
  25. const finished = Promise.withResolvers<FinalOutput>()
  26. const gate = Promise.withResolvers<undefined>()
  27. const entered = Promise.withResolvers<undefined>()
  28. const started = Promise.withResolvers<undefined>()
  29. const peers = new Map<string, { host: Duplex; remote: Duplex }>()
  30. const rpcPeers: SshRpcPeer[] = []
  31. const calls: { method: string; params: unknown }[] = []
  32. let preparedSpec: SubprocessSpawnSpec | undefined
  33. const waitStage = async (stage: string) => {
  34. if (stage === options.pause) { entered.resolve(undefined); await gate.promise }
  35. }
  36. const connection = {
  37. dispose: vi.fn(async () => {}),
  38. request: async <T>(method: string, params: unknown, schema: z.ZodType<T>): Promise<T> => {
  39. calls.push({ method, params })
  40. let value: unknown
  41. if (method === 'process.prepare') {
  42. if (options.failPrepare !== undefined) throw options.failPrepare
  43. preparedSpec = params as SubprocessSpawnSpec
  44. await waitStage('prepare')
  45. const names = ['stdout', 'stderr', ...(preparedSpec.stdio.stdin === 'pipe' ? ['stdin'] : []),
  46. ...(preparedSpec.stdio.control === 'pipe' ? ['control'] : [])]
  47. value = { id, streams: Object.fromEntries(names.map(name => [name, { path: `/tmp/test-${name}`, capability: 'a'.repeat(64) }])) }
  48. } else if (method === 'process.start') {
  49. started.resolve(undefined)
  50. await waitStage('start')
  51. if (options.failStart !== undefined) throw options.failStart
  52. value = {}
  53. } else if (method === 'process.done') value = await finished.promise
  54. else if (method === 'process.wait') value = options.wait ?? true
  55. else if (method === 'process.terminate') {
  56. if (options.failTerminate !== undefined) throw options.failTerminate
  57. value = null
  58. } else if (method === 'executable') value = '/remote/bin/node'
  59. else throw new Error(`Unexpected SSH operation ${method}`)
  60. return schema.parse(value)
  61. },
  62. connectStream: async (endpoint: { path: string }, signal?: AbortSignal): Promise<Duplex> => {
  63. await waitStage('connect')
  64. signal?.throwIfAborted()
  65. const name = endpoint.path.slice('/tmp/test-'.length)
  66. const [host, remote] = duplexPair({ allowHalfOpen: true })
  67. host.on('error', () => {})
  68. remote.on('error', () => {})
  69. peers.set(name, { host, remote })
  70. return host
  71. },
  72. }
  73. ctx.provide('ssh', connection as never)
  74. const fiber = await ctx.plugin(SshSubprocessRuntime)
  75. const close = async () => {
  76. gate.resolve(undefined)
  77. finished.resolve(emptyResult)
  78. for (const peer of rpcPeers) peer.close()
  79. for (const peer of peers.values()) { peer.host.destroy(); peer.remote.destroy() }
  80. return fiber.dispose()
  81. }
  82. onTestFinished(async () => {
  83. await close()
  84. if (options.failTerminate === undefined) expect(disposalErrors).toEqual([])
  85. })
  86. const stream = (name: string): Duplex => {
  87. const peer = peers.get(name)
  88. if (peer === undefined) throw new Error(`stream ${name} has not been connected`)
  89. return peer.remote
  90. }
  91. const snapshots = (name: string, maxBytes: number) => {
  92. const socket = stream(name)
  93. const peer = new SshRpcPeer(socket, socket, outputSnapshotFrameLimit(maxBytes), 1)
  94. rpcPeers.push(peer)
  95. return (tail: string, totalBytes: number, method = 'snapshot') => peer.request(method, {
  96. tail: Buffer.from(tail).toString('base64'), totalBytes,
  97. }, z.null())
  98. }
  99. const closeStream = async (name: string): Promise<void> => {
  100. const peer = peers.get(name)
  101. if (peer === undefined) throw new Error(`stream ${name} has not been connected`)
  102. const closed = new Promise<void>((resolve) => { peer.host.once('close', () => { resolve() }) })
  103. peer.host.destroy()
  104. peer.remote.destroy()
  105. await closed
  106. }
  107. return { runtime: ctx.subprocess, fiber, calls, connection, disposalErrors, finished, entered: entered.promise, started: started.promise,
  108. release: () => { gate.resolve(undefined) }, stream, closeStream, snapshots, close }
  109. }
  110. async function readAll(stream: NodeJS.ReadableStream): Promise<string> {
  111. const chunks: Buffer[] = []
  112. for await (const value of stream) chunks.push(Buffer.from(value as Uint8Array))
  113. return Buffer.concat(chunks).toString()
  114. }
  115. describe('SSH ordinary process behavior', () => {
  116. it('forwards inherited output without ending the host standard streams', async () => {
  117. const test = await setup()
  118. const stdout: unknown[] = []
  119. const stderr: unknown[] = []
  120. const out = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => { stdout.push(chunk); return true })
  121. const err = vi.spyOn(process.stderr, 'write').mockImplementation((chunk: unknown) => { stderr.push(chunk); return true })
  122. try {
  123. const handle = test.runtime.spawn({ ...spec, stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' } })
  124. await test.started
  125. test.stream('stdout').end('inherited output')
  126. test.stream('stderr').end('inherited diagnostics')
  127. test.finished.resolve(emptyResult)
  128. await handle.done
  129. expect(stdout.map(String).join('')).toContain('inherited output')
  130. expect(stderr.map(String).join('')).toContain('inherited diagnostics')
  131. expect(process.stdout.writableEnded).toBe(false)
  132. expect(process.stderr.writableEnded).toBe(false)
  133. expect(handle.stdout).toBeUndefined()
  134. expect(handle.collected).toEqual({})
  135. } finally { out.mockRestore(); err.mockRestore() }
  136. })
  137. it('bounds output draining after process completion even when the output peer remains open', async () => {
  138. const test = await setup()
  139. const handle = test.runtime.spawn(spec)
  140. await test.started
  141. test.finished.resolve(emptyResult)
  142. expect(await handle.done).toEqual({ exitCode: 0, signal: null })
  143. expect(test.stream('stdout').destroyed).toBe(false)
  144. expect(await handle.waitForExit()).toBe(true)
  145. })
  146. it('observes output transports that closed before the start acknowledgement arrives', async () => {
  147. const test = await setup({ pause: 'start' })
  148. const handle = test.runtime.spawn(spec)
  149. await test.entered
  150. await test.closeStream('stdout')
  151. await test.closeStream('stderr')
  152. test.release()
  153. test.finished.resolve(emptyResult)
  154. expect(await handle.done).toEqual({ exitCode: 0, signal: null })
  155. expect(await handle.waitForExit()).toBe(true)
  156. await test.close()
  157. expect(test.disposalErrors).toEqual([])
  158. })
  159. it('keeps stdin, stdout, stderr and control independent and forwards explicit environment removals', async () => {
  160. const test = await setup()
  161. expect(await test.runtime.resolveExecutable('node', { PATH: '/remote/bin' })).toBe('/remote/bin/node')
  162. const handle = test.runtime.spawn({ ...spec, env: { REMOVE: undefined, KEEP: 'value' },
  163. stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', control: 'pipe' } })
  164. const stdout = readAll(handle.stdout!)
  165. const stderr = readAll(handle.stderr!)
  166. const control = readAll(handle.control!)
  167. handle.stdin!.end('ordinary input')
  168. handle.control!.end('private input')
  169. await test.started
  170. expect(await readAll(test.stream('stdin'))).toBe('ordinary input')
  171. expect(await readAll(test.stream('control'))).toBe('private input')
  172. test.stream('stdout').end('ordinary output')
  173. test.stream('stderr').end('diagnostic output')
  174. test.stream('control').end('private output')
  175. expect(await Promise.all([stdout, stderr, control])).toEqual(['ordinary output', 'diagnostic output', 'private output'])
  176. test.finished.resolve(emptyResult)
  177. expect(await handle.done).toEqual({ exitCode: 0, signal: null })
  178. expect(await handle.waitForExit()).toBe(true)
  179. expect(await handle.waitForExit()).toBe(true)
  180. expect(test.calls.find(call => call.method === 'process.prepare')?.params).toMatchObject({ env: { REMOVE: null, KEEP: 'value' } })
  181. handle.terminate()
  182. expect(test.calls.filter(call => call.method === 'process.terminate')).toHaveLength(0)
  183. })
  184. it('retains authoritative collected byte offsets when older stream snapshots arrive after completion', async () => {
  185. const test = await setup()
  186. const handle = test.runtime.spawn({ ...spec, stdio: { stdin: 'ignore', stdout: { maxBytes: 4 }, stderr: { maxBytes: 4 } } })
  187. await test.started
  188. const snapshot = test.snapshots('stdout', 4)
  189. await snapshot('cdef', 6)
  190. expect(handle.collected.stdout!.readFrom(0)).toEqual({ text: 'cdef', nextOffset: 6, lossy: true })
  191. test.finished.resolve({ ...emptyResult, spills: { stdout: '/remote/spill' }, collected: {
  192. stdout: { tail: Buffer.from('ghij').toString('base64'), totalBytes: 10 }, stderr: { tail: '', totalBytes: 0 },
  193. } })
  194. await handle.done
  195. await snapshot('x', 1)
  196. expect(handle.collected.stdout!.readFrom(8)).toEqual({ text: 'ij', nextOffset: 10, lossy: false, spillPath: '/remote/spill' })
  197. })
  198. it.each([
  199. { tail: '12345', total: 5, error: 'invalid collected output coordinates' },
  200. { tail: 'abcd', total: 3, error: 'invalid collected output coordinates' },
  201. { tail: 'a', total: 1, error: 'rewound collected output' },
  202. ])('rejects a remote snapshot with $error', async ({ tail, total, error }) => {
  203. const test = await setup()
  204. const handle = test.runtime.spawn({ ...spec, stdio: { stdin: 'ignore', stdout: { maxBytes: 4 }, stderr: { maxBytes: 4 } } })
  205. await test.started
  206. const snapshot = test.snapshots('stdout', 4)
  207. await snapshot('abcd', 4)
  208. await expect(snapshot(tail, total)).rejects.toThrow(error)
  209. await expect(snapshot('a', 1, 'unexpected')).rejects.toThrow('Unexpected SSH output-stream operation')
  210. expect(handle.collected.stdout!.readFrom(0).text).toBe('abcd')
  211. test.finished.resolve({ ...emptyResult, collected: { stdout: { tail: 'YWJjZA==', totalBytes: 4 }, stderr: { tail: '', totalBytes: 0 } } })
  212. await handle.done
  213. })
  214. it('rejects a final output mode mismatch and terminates its remote process', async () => {
  215. const test = await setup()
  216. const handle = test.runtime.spawn({ ...spec, stdio: { stdin: 'ignore', stdout: { maxBytes: 4 }, stderr: { maxBytes: 4 } } })
  217. await test.started
  218. test.finished.resolve(emptyResult)
  219. await expect(handle.done).rejects.toThrow('mismatched output collection modes')
  220. expect(await handle.waitForExit()).toBe(true)
  221. expect(test.calls.filter(call => call.method === 'process.terminate')).toHaveLength(1)
  222. })
  223. it.each(['prepare', 'connect', 'start'] as const)('joins cancellation during %s and sends at most one termination', async (pause) => {
  224. const test = await setup({ pause })
  225. const controller = new AbortController()
  226. const handle = test.runtime.spawn({ ...spec, signal: controller.signal })
  227. await test.entered
  228. controller.abort(new Error('caller stopped'))
  229. handle.terminate()
  230. const quiescent = handle.waitForExit()
  231. test.release()
  232. if (pause === 'start') test.finished.resolve(emptyResult)
  233. await Promise.allSettled([handle.done])
  234. expect(await quiescent).toBe(true)
  235. handle.terminate()
  236. expect(test.calls.filter(call => call.method === 'process.terminate')).toHaveLength(1)
  237. if (pause !== 'start') expect(test.calls.some(call => call.method === 'process.start')).toBe(false)
  238. })
  239. it('reports range completion when preparation fails before any process can start', async () => {
  240. const test = await setup({ failPrepare: new Error('prepare refused') })
  241. const handle = test.runtime.spawn(spec)
  242. const quiescent = handle.waitForExit()
  243. await expect(handle.done).rejects.toThrow('prepare refused')
  244. expect(await quiescent).toBe(true)
  245. expect(test.calls.some(call => call.method === 'process.start' || call.method === 'process.terminate')).toBe(false)
  246. })
  247. it('reports an unconfirmed cleanup and closes the SSH connection', async () => {
  248. const test = await setup({ failStart: new Error('start lost'), failTerminate: new Error('cleanup lost') })
  249. test.connection.dispose.mockRejectedValue(new Error('connection cleanup failed'))
  250. const handle = test.runtime.spawn(spec)
  251. await expect(handle.done).rejects.toThrow('start lost')
  252. await expect(handle.waitForExit()).rejects.toThrow('cleanup lost')
  253. await test.close()
  254. expect(test.disposalErrors).toEqual([expect.objectContaining({ message: 'SSH process cleanup could not be confirmed' })])
  255. expect(test.connection.dispose).toHaveBeenCalled()
  256. })
  257. it('normalizes a non-Error transport rejection for stream consumers', async () => {
  258. const test = await setup()
  259. const handle = test.runtime.spawn(spec)
  260. const error = new Promise<Error>(resolve => handle.stdout!.once('error', resolve))
  261. await test.started
  262. test.finished.reject('connection lost')
  263. await expect(handle.done).rejects.toBe('connection lost')
  264. expect((await error).message).toBe('connection lost')
  265. expect(await handle.waitForExit()).toBe(true)
  266. })
  267. it('reports a live range until termination and rejects new work after disposal', async () => {
  268. const test = await setup({ wait: false })
  269. const handle = test.runtime.spawn(spec)
  270. await test.started
  271. expect(await handle.waitForExit()).toBe(false)
  272. handle.terminate()
  273. expect(await handle.waitForExit()).toBe(true)
  274. await test.close()
  275. expect(() => test.runtime.spawn(spec)).toThrow('disposed')
  276. expect(() => test.runtime.spawn({ ...spec, signal: AbortSignal.abort() })).toThrow()
  277. })
  278. })