| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344 |
- /** Remote subprocess and PTY handles with independent SSH streams and helper-owned process lifetimes. */
- import { Duplex, PassThrough, type Readable, type Writable } from 'node:stream'
- import type { Socket } from 'node:net'
- import { Context } from '@deepseek-ai/cordis'
- import { SubprocessRuntime, SubprocessExecutableNotFoundError } from '@deepseek-ai/dsh-subprocess'
- import type { SubprocessCollectedOutputs, SubprocessHandle, SubprocessOutcome, SubprocessOutputMode, SubprocessSpawnSpec, SubprocessTerminalHandle, SubprocessTerminalEnvironment, SubprocessTerminalSignal, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess'
- import { OutputCollector } from '@deepseek-ai/dsh-subprocess-local/output'
- import type { SshConnection } from '@deepseek-ai/dsh-ssh'
- import { doneSchema, foregroundSchema, outputSnapshotFrameLimit, outputSnapshotSchema, preparedSchema, remotePath, streamEndpointSchema } from '@deepseek-ai/dsh-ssh/schemas'
- import type { SshProcessId } from '@deepseek-ai/dsh-ssh/schemas'
- import { SshRpcPeer, RemoteOperationError } from '@deepseek-ai/dsh-ssh/protocol'
- import { z } from 'zod'
- function environment(env?: NodeJS.ProcessEnv): Record<string, string | null> | undefined {
- return env === undefined ? undefined : Object.fromEntries(Object.entries(env).map(([key, value]) => [key, value ?? null]))
- }
- class RemoteCleanupError extends AggregateError {}
- /** One remote ordinary process; stdin and control remain usable during asynchronous SSH allocation. */
- class RemoteProcess implements SubprocessHandle {
- readonly stdin: Writable | undefined
- readonly stdout: Readable | undefined
- readonly stderr: Readable | undefined
- readonly control: Duplex | undefined
- readonly collected: SubprocessCollectedOutputs
- readonly done: Promise<SubprocessOutcome>
- readonly streamsClosed: Promise<void>
- private readonly inbound = new PassThrough()
- private readonly out = new PassThrough()
- private readonly err = new PassThrough()
- private readonly toControl = new PassThrough()
- private readonly fromControl = new PassThrough()
- private readonly controller = new AbortController()
- private readonly started: Promise<void>
- private id: SshProcessId | undefined
- private sockets: Socket[] = []
- private quiescent = false
- private committed = false
- private termination: Promise<void> | undefined
- private readonly detachAbort: () => void
- private spills: { stdout?: string | undefined; stderr?: string | undefined } = {}
- private readonly updateCollection: Partial<Record<'stdout' | 'stderr', (snapshot: { tail: string; totalBytes: number }, final: boolean) => void>> = {}
- constructor(private readonly ssh: SshConnection, private readonly spec: SubprocessSpawnSpec) {
- this.stdin = spec.stdio.stdin === 'pipe' ? this.inbound : undefined
- this.stdout = spec.stdio.stdout === 'pipe' ? this.out : undefined
- this.stderr = spec.stdio.stderr === 'pipe' ? this.err : undefined
- const requestedControl = (spec.stdio as typeof spec.stdio & { control?: 'pipe' }).control
- this.control = requestedControl === undefined ? undefined : Duplex.from({ writable: this.toControl, readable: this.fromControl })
- for (const stream of [this.inbound, this.out, this.err, this.toControl, this.fromControl, this.control]) stream?.on('error', () => {})
- const collect = (name: 'stdout' | 'stderr', stream: PassThrough, mode: SubprocessOutputMode) => {
- if (mode === 'pipe') return undefined
- if (mode === 'inherit') { stream.pipe(name === 'stdout' ? process.stdout : process.stderr, { end: false }); return undefined }
- let collector = new OutputCollector(mode.maxBytes, undefined, name, '')
- let base = 0
- let total = 0
- let finalized = false
- this.updateCollection[name] = (snapshot, final): void => {
- const bytes = Buffer.from(snapshot.tail, 'base64')
- if (bytes.length > mode.maxBytes || snapshot.totalBytes < bytes.length) throw new Error('SSH helper returned invalid collected output coordinates')
- if (finalized) return
- if (snapshot.totalBytes < total) throw new Error('SSH helper rewound collected output')
- finalized = final
- collector = new OutputCollector(mode.maxBytes, undefined, name, '')
- collector.push(bytes)
- base = snapshot.totalBytes - bytes.length
- total = snapshot.totalBytes
- }
- return {
- readFrom: (offset: number) => {
- const snapshot = collector.readFrom(offset - base)
- return {
- ...snapshot, nextOffset: snapshot.nextOffset + base,
- ...(this.spills[name] === undefined ? {} : { spillPath: this.spills[name] }),
- }
- },
- }
- }
- const stdout = collect('stdout', this.out, spec.stdio.stdout)
- const stderr = collect('stderr', this.err, spec.stdio.stderr)
- this.collected = { ...(stdout === undefined ? {} : { stdout }), ...(stderr === undefined ? {} : { stderr }) }
- const onAbort = (): void => { this.terminate() }
- spec.signal?.addEventListener('abort', onAbort, { once: true })
- this.detachAbort = () => { spec.signal?.removeEventListener('abort', onAbort) }
- this.started = this.start()
- this.streamsClosed = this.started.then(async () => {
- await Promise.all(this.sockets.map(socket => socket.closed ? Promise.resolve() : new Promise<void>((resolve) => {
- socket.once('close', () => { resolve() })
- })))
- }, () => {})
- this.done = this.started.then(async () => {
- const result = await ssh.request('process.done', { id: this.id }, doneSchema, undefined, true)
- await this.drainOutput()
- this.spills = result.spills
- for (const name of ['stdout', 'stderr'] as const) {
- const snapshot = result.collected[name]
- const finish = this.updateCollection[name]
- if ((snapshot === undefined) !== (finish === undefined)) throw new Error('SSH helper returned mismatched output collection modes')
- if (snapshot !== undefined) finish?.(snapshot, true)
- }
- return { exitCode: result.outcome.exitCode, signal: result.outcome.signal as NodeJS.Signals | null }
- }).catch((error: unknown) => {
- this.terminate()
- for (const socket of this.sockets) socket.destroy()
- for (const stream of [this.inbound, this.out, this.err, this.toControl, this.fromControl]) {
- stream.destroy(error instanceof Error ? error : new Error(String(error)))
- }
- throw error
- })
- void this.done.catch(() => {})
- }
- private async start(): Promise<void> {
- this.spec.signal?.throwIfAborted()
- const prepared = await this.ssh.request(
- 'process.prepare', { ...this.spec, signal: undefined, env: environment(this.spec.env) }, preparedSchema, this.controller.signal,
- )
- this.id = prepared.id
- try {
- const sockets = await Promise.all(Object.entries(prepared.streams).map(async ([name, path]) =>
- [name, await this.ssh.connectStream(path, this.controller.signal)] as const))
- this.sockets = sockets.map(([, socket]) => socket)
- for (const [name, socket] of sockets) {
- if (name === 'stdout' || name === 'stderr') {
- const mode = this.spec.stdio[name]
- if (typeof mode === 'object') {
- new SshRpcPeer(socket, socket, outputSnapshotFrameLimit(mode.maxBytes), 1, (method, raw) => Promise.resolve().then(() => {
- if (method !== 'snapshot') throw new Error('Unexpected SSH output-stream operation')
- this.updateCollection[name]?.(outputSnapshotSchema.parse(raw), false)
- return null
- }))
- } else {
- socket.end()
- const output = name === 'stdout' ? this.out : this.err
- const closeSocket = (): void => { socket.destroy() }
- output.once('close', closeSocket)
- socket.once('close', () => { output.off('close', closeSocket) })
- if (output.destroyed) closeSocket()
- else socket.pipe(output)
- }
- }
- if (name === 'stdin') this.inbound.pipe(socket)
- if (name === 'control') { this.toControl.pipe(socket); socket.pipe(this.fromControl) }
- }
- this.controller.signal.throwIfAborted()
- await this.ssh.request('process.start', { id: this.id }, z.object({}).strict(), this.controller.signal)
- this.committed = true
- } catch (error) {
- this.terminate()
- await this.termination?.catch(() => {})
- for (const socket of this.sockets) socket.destroy()
- throw error
- }
- }
- terminate(): void {
- if (this.quiescent || this.termination !== undefined) return
- if (!this.committed) this.controller.abort(new Error('SSH process terminated before launch acknowledgement'))
- if (this.id !== undefined) {
- this.termination = this.ssh.request('process.terminate', { id: this.id }, z.null()).then(() => {
- this.quiescent = true
- this.detachAbort()
- }).catch((error: unknown) => {
- // A connection unable to confirm termination must release its helper lease.
- void this.ssh.dispose().catch(() => {})
- throw error
- })
- void this.termination.catch(() => {})
- }
- }
- closeStreams(): void {
- for (const socket of this.sockets) socket.destroy()
- for (const stream of [this.inbound, this.out, this.err, this.toControl, this.fromControl, this.control]) stream?.destroy()
- }
- async waitForExit(signal?: AbortSignal): Promise<boolean> {
- if (this.quiescent) return true
- if (signal?.aborted) return false
- if (signal === undefined) return this.observeExit()
- const cancelled = Promise.withResolvers<boolean>()
- const abort = (): void => { cancelled.resolve(false) }
- signal.addEventListener('abort', abort, { once: true })
- try { return await Promise.race([this.observeExit(signal), cancelled.promise]) }
- finally { signal.removeEventListener('abort', abort) }
- }
- private async observeExit(signal?: AbortSignal): Promise<boolean> {
- try { await this.started } catch {
- // Startup errors remain on done; a prepared process must first finish termination.
- if (this.termination !== undefined) await this.termination
- this.quiescent = true
- this.detachAbort()
- return true
- }
- if (this.termination !== undefined) { await this.termination; return true }
- const result = await this.ssh.request('process.wait', { id: this.id }, z.boolean(), signal, true)
- if (result) { this.quiescent = true; this.detachAbort() }
- return result
- }
- private async drainOutput(): Promise<void> {
- const disposers: Array<() => void> = []
- const outputs = (['stdout', 'stderr'] as const).map((name) => {
- if (typeof this.spec.stdio[name] === 'object') return Promise.resolve()
- const stream = name === 'stdout' ? this.out : this.err
- if (stream.readableEnded || stream.destroyed) return Promise.resolve()
- return new Promise<void>((resolve) => {
- const done = (): void => { resolve() }
- for (const event of ['end', 'close', 'error']) stream.once(event, done)
- disposers.push(() => { for (const event of ['end', 'close', 'error']) stream.off(event, done) })
- })
- })
- let timer: NodeJS.Timeout | undefined
- try {
- await Promise.race([
- Promise.all(outputs),
- new Promise<void>((resolve) => { timer = setTimeout(resolve, this.spec.graceMs) }),
- ])
- } finally {
- clearTimeout(timer)
- for (const dispose of disposers) dispose()
- }
- }
- }
- /** SSH provider paired with the SSH filesystem; the remote helper selects POSIX process ownership. */
- export class SshSubprocessRuntime extends SubprocessRuntime {
- static inject = ['ssh']
- private readonly live = new Set<RemoteProcess>()
- private readonly terminals = new Set<SubprocessTerminalHandle>()
- private readonly terminalAllocations = new Set<Promise<SubprocessTerminalHandle>>()
- private readonly lifetime = new AbortController()
- constructor(ctx: Context) {
- super(ctx)
- ctx.effect(() => async () => {
- this.lifetime.abort(new Error('SSH subprocess provider disposed'))
- for (const handle of this.live) handle.terminate()
- const results = await Promise.allSettled([
- ...[...this.live].map(async (handle) => {
- try { await handle.waitForExit() } finally { handle.closeStreams() }
- }),
- ...[...this.terminalAllocations].map(allocation => allocation.catch((error: unknown) => {
- if (error instanceof RemoteCleanupError) throw error
- })),
- ...[...this.terminals].map(handle => handle.terminate()),
- ])
- const errors = results.flatMap(result => result.status === 'rejected' ? [result.reason as unknown] : [])
- if (errors.length > 0) throw new AggregateError(errors, 'SSH process cleanup could not be confirmed')
- })
- }
- override async resolveExecutable(command: string, env?: Readonly<Record<string, string>>, signal?: AbortSignal): Promise<string> {
- try {
- return await this.ctx.ssh.request('executable', { command, env }, remotePath, signal)
- } catch (error) {
- if (error instanceof RemoteOperationError && error.code === 'SUBPROCESS_EXECUTABLE_NOT_FOUND') {
- throw new SubprocessExecutableNotFoundError(error.message, { cause: error })
- }
- throw error
- }
- }
- override terminalEnvironment(signal?: AbortSignal): Promise<SubprocessTerminalEnvironment> {
- return this.ctx.ssh.request('terminal.environment', {}, z.object({
- platform: z.enum(['posix', 'windows']), defaultShell: z.string().optional(),
- }).strict().transform(value => ({ platform: value.platform,
- ...(value.defaultShell === undefined ? {} : { defaultShell: value.defaultShell }),
- })), signal)
- }
- override spawn(spec: SubprocessSpawnSpec): SubprocessHandle {
- this.lifetime.signal.throwIfAborted()
- spec.signal?.throwIfAborted()
- const handle = new RemoteProcess(this.ctx.ssh, spec)
- this.live.add(handle)
- void handle.done.then(() => handle.waitForExit()).then(() => handle.streamsClosed)
- .then(() => { this.live.delete(handle) }).catch(() => {})
- return handle
- }
- override async spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle> {
- this.lifetime.signal.throwIfAborted()
- const signal = spec.signal === undefined ? this.lifetime.signal : AbortSignal.any([spec.signal, this.lifetime.signal])
- signal.throwIfAborted()
- const allocation = this.createTerminal(spec, signal)
- this.terminalAllocations.add(allocation)
- try { return await allocation } finally { this.terminalAllocations.delete(allocation) }
- }
- private async createTerminal(spec: SubprocessTerminalSpawnSpec, signal: AbortSignal): Promise<SubprocessTerminalHandle> {
- const ssh = this.ctx.ssh
- const prepared = await ssh.request('process.prepare', {
- argv: spec.argv, cwd: spec.cwd, env: environment(spec.env), graceMs: spec.graceMs,
- terminal: { rows: spec.rows, cols: spec.cols, terminalType: spec.terminalType },
- }, preparedSchema, signal)
- const id = prepared.id
- let socket: Socket | undefined
- try {
- signal.throwIfAborted()
- socket = await ssh.connectStream(streamEndpointSchema.parse(prepared.streams.terminal), signal)
- signal.throwIfAborted()
- socket.end()
- const output = new PassThrough()
- socket.pipe(output)
- const started = await ssh.request('process.start', { id }, z.object({ pid: z.number().int().positive() }).strict(), signal)
- signal.throwIfAborted()
- const done = ssh.request('process.done', { id }, doneSchema, undefined, true).then(result => ({ exitCode: result.outcome.exitCode, signal: result.outcome.signal as NodeJS.Signals | null }))
- void done.catch(() => {})
- let closing: Promise<void> | undefined
- const abort = (): void => { void handle.terminate().catch(() => { void ssh.dispose().catch(() => {}) }) }
- const handle: SubprocessTerminalHandle = {
- pid: started.pid, output, done,
- resize: async (cols, rows) => { await ssh.request('terminal.resize', { id, cols, rows }, z.null()) },
- write: async (data) => { await ssh.request('terminal.write', { id, value: data }, z.null()) },
- inspectForeground: async () => await ssh.request('terminal.inspect', { id }, foregroundSchema) ?? undefined,
- signalForeground: (signal: SubprocessTerminalSignal) => ssh.request('terminal.signal', { id, value: signal }, z.number().int().positive()),
- terminate: () => {
- closing ??= ssh.request('process.terminate', { id }, z.null(), undefined, true).then(() => {
- socket?.destroy()
- output.destroy()
- signal.removeEventListener('abort', abort)
- this.terminals.delete(handle)
- }).catch((error: unknown) => { closing = undefined; throw error })
- return closing
- },
- }
- signal.addEventListener('abort', abort, { once: true })
- this.terminals.add(handle)
- return handle
- } catch (error) {
- socket?.destroy()
- try { await ssh.request('process.terminate', { id }, z.null()) } catch (cleanupError) {
- void ssh.dispose().catch(() => {})
- throw new RemoteCleanupError([error, cleanupError], 'SSH terminal allocation failed and remote cleanup is unknown')
- }
- throw error
- }
- }
- }
- export default SshSubprocessRuntime
|