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