index.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. /** Remote subprocess and PTY handles with independent SSH streams and helper-owned process lifetimes. */
  2. import { Duplex, PassThrough, type Readable, type Writable } from 'node:stream'
  3. import type { Socket } from 'node:net'
  4. import { Context } from '@deepseek-ai/cordis'
  5. import { SubprocessRuntime, SubprocessExecutableNotFoundError } from '@deepseek-ai/dsh-subprocess'
  6. import type { SubprocessCollectedOutputs, SubprocessHandle, SubprocessOutcome, SubprocessOutputMode, SubprocessSpawnSpec, SubprocessTerminalHandle, SubprocessTerminalEnvironment, SubprocessTerminalSignal, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess'
  7. import { OutputCollector } from '@deepseek-ai/dsh-subprocess-local/output'
  8. import type { SshConnection } from '@deepseek-ai/dsh-ssh'
  9. import { doneSchema, foregroundSchema, outputSnapshotFrameLimit, outputSnapshotSchema, preparedSchema, remotePath, streamEndpointSchema } from '@deepseek-ai/dsh-ssh/schemas'
  10. import type { SshProcessId } from '@deepseek-ai/dsh-ssh/schemas'
  11. import { SshRpcPeer, RemoteOperationError } from '@deepseek-ai/dsh-ssh/protocol'
  12. import { z } from 'zod'
  13. function environment(env?: NodeJS.ProcessEnv): Record<string, string | null> | undefined {
  14. return env === undefined ? undefined : Object.fromEntries(Object.entries(env).map(([key, value]) => [key, value ?? null]))
  15. }
  16. class RemoteCleanupError extends AggregateError {}
  17. /** One remote ordinary process; stdin and control remain usable during asynchronous SSH allocation. */
  18. class RemoteProcess implements SubprocessHandle {
  19. readonly stdin: Writable | undefined
  20. readonly stdout: Readable | undefined
  21. readonly stderr: Readable | undefined
  22. readonly control: Duplex | undefined
  23. readonly collected: SubprocessCollectedOutputs
  24. readonly done: Promise<SubprocessOutcome>
  25. readonly streamsClosed: Promise<void>
  26. private readonly inbound = new PassThrough()
  27. private readonly out = new PassThrough()
  28. private readonly err = new PassThrough()
  29. private readonly toControl = new PassThrough()
  30. private readonly fromControl = new PassThrough()
  31. private readonly controller = new AbortController()
  32. private readonly started: Promise<void>
  33. private id: SshProcessId | undefined
  34. private sockets: Socket[] = []
  35. private quiescent = false
  36. private committed = false
  37. private termination: Promise<void> | undefined
  38. private readonly detachAbort: () => void
  39. private spills: { stdout?: string | undefined; stderr?: string | undefined } = {}
  40. private readonly updateCollection: Partial<Record<'stdout' | 'stderr', (snapshot: { tail: string; totalBytes: number }, final: boolean) => void>> = {}
  41. constructor(private readonly ssh: SshConnection, private readonly spec: SubprocessSpawnSpec) {
  42. this.stdin = spec.stdio.stdin === 'pipe' ? this.inbound : undefined
  43. this.stdout = spec.stdio.stdout === 'pipe' ? this.out : undefined
  44. this.stderr = spec.stdio.stderr === 'pipe' ? this.err : undefined
  45. const requestedControl = (spec.stdio as typeof spec.stdio & { control?: 'pipe' }).control
  46. this.control = requestedControl === undefined ? undefined : Duplex.from({ writable: this.toControl, readable: this.fromControl })
  47. for (const stream of [this.inbound, this.out, this.err, this.toControl, this.fromControl, this.control]) stream?.on('error', () => {})
  48. const collect = (name: 'stdout' | 'stderr', stream: PassThrough, mode: SubprocessOutputMode) => {
  49. if (mode === 'pipe') return undefined
  50. if (mode === 'inherit') { stream.pipe(name === 'stdout' ? process.stdout : process.stderr, { end: false }); return undefined }
  51. let collector = new OutputCollector(mode.maxBytes, undefined, name, '')
  52. let base = 0
  53. let total = 0
  54. let finalized = false
  55. this.updateCollection[name] = (snapshot, final): void => {
  56. const bytes = Buffer.from(snapshot.tail, 'base64')
  57. if (bytes.length > mode.maxBytes || snapshot.totalBytes < bytes.length) throw new Error('SSH helper returned invalid collected output coordinates')
  58. if (finalized) return
  59. if (snapshot.totalBytes < total) throw new Error('SSH helper rewound collected output')
  60. finalized = final
  61. collector = new OutputCollector(mode.maxBytes, undefined, name, '')
  62. collector.push(bytes)
  63. base = snapshot.totalBytes - bytes.length
  64. total = snapshot.totalBytes
  65. }
  66. return {
  67. readFrom: (offset: number) => {
  68. const snapshot = collector.readFrom(offset - base)
  69. return {
  70. ...snapshot, nextOffset: snapshot.nextOffset + base,
  71. ...(this.spills[name] === undefined ? {} : { spillPath: this.spills[name] }),
  72. }
  73. },
  74. }
  75. }
  76. const stdout = collect('stdout', this.out, spec.stdio.stdout)
  77. const stderr = collect('stderr', this.err, spec.stdio.stderr)
  78. this.collected = { ...(stdout === undefined ? {} : { stdout }), ...(stderr === undefined ? {} : { stderr }) }
  79. const onAbort = (): void => { this.terminate() }
  80. spec.signal?.addEventListener('abort', onAbort, { once: true })
  81. this.detachAbort = () => { spec.signal?.removeEventListener('abort', onAbort) }
  82. this.started = this.start()
  83. this.streamsClosed = this.started.then(async () => {
  84. await Promise.all(this.sockets.map(socket => socket.closed ? Promise.resolve() : new Promise<void>((resolve) => {
  85. socket.once('close', () => { resolve() })
  86. })))
  87. }, () => {})
  88. this.done = this.started.then(async () => {
  89. const result = await ssh.request('process.done', { id: this.id }, doneSchema, undefined, true)
  90. await this.drainOutput()
  91. this.spills = result.spills
  92. for (const name of ['stdout', 'stderr'] as const) {
  93. const snapshot = result.collected[name]
  94. const finish = this.updateCollection[name]
  95. if ((snapshot === undefined) !== (finish === undefined)) throw new Error('SSH helper returned mismatched output collection modes')
  96. if (snapshot !== undefined) finish?.(snapshot, true)
  97. }
  98. return { exitCode: result.outcome.exitCode, signal: result.outcome.signal as NodeJS.Signals | null }
  99. }).catch((error: unknown) => {
  100. this.terminate()
  101. for (const socket of this.sockets) socket.destroy()
  102. for (const stream of [this.inbound, this.out, this.err, this.toControl, this.fromControl]) {
  103. stream.destroy(error instanceof Error ? error : new Error(String(error)))
  104. }
  105. throw error
  106. })
  107. void this.done.catch(() => {})
  108. }
  109. private async start(): Promise<void> {
  110. this.spec.signal?.throwIfAborted()
  111. const prepared = await this.ssh.request(
  112. 'process.prepare', { ...this.spec, signal: undefined, env: environment(this.spec.env) }, preparedSchema, this.controller.signal,
  113. )
  114. this.id = prepared.id
  115. try {
  116. const sockets = await Promise.all(Object.entries(prepared.streams).map(async ([name, path]) =>
  117. [name, await this.ssh.connectStream(path, this.controller.signal)] as const))
  118. this.sockets = sockets.map(([, socket]) => socket)
  119. for (const [name, socket] of sockets) {
  120. if (name === 'stdout' || name === 'stderr') {
  121. const mode = this.spec.stdio[name]
  122. if (typeof mode === 'object') {
  123. new SshRpcPeer(socket, socket, outputSnapshotFrameLimit(mode.maxBytes), 1, (method, raw) => Promise.resolve().then(() => {
  124. if (method !== 'snapshot') throw new Error('Unexpected SSH output-stream operation')
  125. this.updateCollection[name]?.(outputSnapshotSchema.parse(raw), false)
  126. return null
  127. }))
  128. } else {
  129. socket.end()
  130. const output = name === 'stdout' ? this.out : this.err
  131. const closeSocket = (): void => { socket.destroy() }
  132. output.once('close', closeSocket)
  133. socket.once('close', () => { output.off('close', closeSocket) })
  134. if (output.destroyed) closeSocket()
  135. else socket.pipe(output)
  136. }
  137. }
  138. if (name === 'stdin') this.inbound.pipe(socket)
  139. if (name === 'control') { this.toControl.pipe(socket); socket.pipe(this.fromControl) }
  140. }
  141. this.controller.signal.throwIfAborted()
  142. await this.ssh.request('process.start', { id: this.id }, z.object({}).strict(), this.controller.signal)
  143. this.committed = true
  144. } catch (error) {
  145. this.terminate()
  146. await this.termination?.catch(() => {})
  147. for (const socket of this.sockets) socket.destroy()
  148. throw error
  149. }
  150. }
  151. terminate(): void {
  152. if (this.quiescent || this.termination !== undefined) return
  153. if (!this.committed) this.controller.abort(new Error('SSH process terminated before launch acknowledgement'))
  154. if (this.id !== undefined) {
  155. this.termination = this.ssh.request('process.terminate', { id: this.id }, z.null()).then(() => {
  156. this.quiescent = true
  157. this.detachAbort()
  158. }).catch((error: unknown) => {
  159. // A connection unable to confirm termination must release its helper lease.
  160. void this.ssh.dispose().catch(() => {})
  161. throw error
  162. })
  163. void this.termination.catch(() => {})
  164. }
  165. }
  166. closeStreams(): void {
  167. for (const socket of this.sockets) socket.destroy()
  168. for (const stream of [this.inbound, this.out, this.err, this.toControl, this.fromControl, this.control]) stream?.destroy()
  169. }
  170. async waitForExit(signal?: AbortSignal): Promise<boolean> {
  171. if (this.quiescent) return true
  172. if (signal?.aborted) return false
  173. if (signal === undefined) return this.observeExit()
  174. const cancelled = Promise.withResolvers<boolean>()
  175. const abort = (): void => { cancelled.resolve(false) }
  176. signal.addEventListener('abort', abort, { once: true })
  177. try { return await Promise.race([this.observeExit(signal), cancelled.promise]) }
  178. finally { signal.removeEventListener('abort', abort) }
  179. }
  180. private async observeExit(signal?: AbortSignal): Promise<boolean> {
  181. try { await this.started } catch {
  182. // Startup errors remain on done; a prepared process must first finish termination.
  183. if (this.termination !== undefined) await this.termination
  184. this.quiescent = true
  185. this.detachAbort()
  186. return true
  187. }
  188. if (this.termination !== undefined) { await this.termination; return true }
  189. const result = await this.ssh.request('process.wait', { id: this.id }, z.boolean(), signal, true)
  190. if (result) { this.quiescent = true; this.detachAbort() }
  191. return result
  192. }
  193. private async drainOutput(): Promise<void> {
  194. const disposers: Array<() => void> = []
  195. const outputs = (['stdout', 'stderr'] as const).map((name) => {
  196. if (typeof this.spec.stdio[name] === 'object') return Promise.resolve()
  197. const stream = name === 'stdout' ? this.out : this.err
  198. if (stream.readableEnded || stream.destroyed) return Promise.resolve()
  199. return new Promise<void>((resolve) => {
  200. const done = (): void => { resolve() }
  201. for (const event of ['end', 'close', 'error']) stream.once(event, done)
  202. disposers.push(() => { for (const event of ['end', 'close', 'error']) stream.off(event, done) })
  203. })
  204. })
  205. let timer: NodeJS.Timeout | undefined
  206. try {
  207. await Promise.race([
  208. Promise.all(outputs),
  209. new Promise<void>((resolve) => { timer = setTimeout(resolve, this.spec.graceMs) }),
  210. ])
  211. } finally {
  212. clearTimeout(timer)
  213. for (const dispose of disposers) dispose()
  214. }
  215. }
  216. }
  217. /** SSH provider paired with the SSH filesystem; the remote helper selects POSIX process ownership. */
  218. export class SshSubprocessRuntime extends SubprocessRuntime {
  219. static inject = ['ssh']
  220. private readonly live = new Set<RemoteProcess>()
  221. private readonly terminals = new Set<SubprocessTerminalHandle>()
  222. private readonly terminalAllocations = new Set<Promise<SubprocessTerminalHandle>>()
  223. private readonly lifetime = new AbortController()
  224. constructor(ctx: Context) {
  225. super(ctx)
  226. ctx.effect(() => async () => {
  227. this.lifetime.abort(new Error('SSH subprocess provider disposed'))
  228. for (const handle of this.live) handle.terminate()
  229. const results = await Promise.allSettled([
  230. ...[...this.live].map(async (handle) => {
  231. try { await handle.waitForExit() } finally { handle.closeStreams() }
  232. }),
  233. ...[...this.terminalAllocations].map(allocation => allocation.catch((error: unknown) => {
  234. if (error instanceof RemoteCleanupError) throw error
  235. })),
  236. ...[...this.terminals].map(handle => handle.terminate()),
  237. ])
  238. const errors = results.flatMap(result => result.status === 'rejected' ? [result.reason as unknown] : [])
  239. if (errors.length > 0) throw new AggregateError(errors, 'SSH process cleanup could not be confirmed')
  240. })
  241. }
  242. override async resolveExecutable(command: string, env?: Readonly<Record<string, string>>, signal?: AbortSignal): Promise<string> {
  243. try {
  244. return await this.ctx.ssh.request('executable', { command, env }, remotePath, signal)
  245. } catch (error) {
  246. if (error instanceof RemoteOperationError && error.code === 'SUBPROCESS_EXECUTABLE_NOT_FOUND') {
  247. throw new SubprocessExecutableNotFoundError(error.message, { cause: error })
  248. }
  249. throw error
  250. }
  251. }
  252. override terminalEnvironment(signal?: AbortSignal): Promise<SubprocessTerminalEnvironment> {
  253. return this.ctx.ssh.request('terminal.environment', {}, z.object({
  254. platform: z.enum(['posix', 'windows']), defaultShell: z.string().optional(),
  255. }).strict().transform(value => ({ platform: value.platform,
  256. ...(value.defaultShell === undefined ? {} : { defaultShell: value.defaultShell }),
  257. })), signal)
  258. }
  259. override spawn(spec: SubprocessSpawnSpec): SubprocessHandle {
  260. this.lifetime.signal.throwIfAborted()
  261. spec.signal?.throwIfAborted()
  262. const handle = new RemoteProcess(this.ctx.ssh, spec)
  263. this.live.add(handle)
  264. void handle.done.then(() => handle.waitForExit()).then(() => handle.streamsClosed)
  265. .then(() => { this.live.delete(handle) }).catch(() => {})
  266. return handle
  267. }
  268. override async spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle> {
  269. this.lifetime.signal.throwIfAborted()
  270. const signal = spec.signal === undefined ? this.lifetime.signal : AbortSignal.any([spec.signal, this.lifetime.signal])
  271. signal.throwIfAborted()
  272. const allocation = this.createTerminal(spec, signal)
  273. this.terminalAllocations.add(allocation)
  274. try { return await allocation } finally { this.terminalAllocations.delete(allocation) }
  275. }
  276. private async createTerminal(spec: SubprocessTerminalSpawnSpec, signal: AbortSignal): Promise<SubprocessTerminalHandle> {
  277. const ssh = this.ctx.ssh
  278. const prepared = await ssh.request('process.prepare', {
  279. argv: spec.argv, cwd: spec.cwd, env: environment(spec.env), graceMs: spec.graceMs,
  280. terminal: { rows: spec.rows, cols: spec.cols, terminalType: spec.terminalType },
  281. }, preparedSchema, signal)
  282. const id = prepared.id
  283. let socket: Socket | undefined
  284. try {
  285. signal.throwIfAborted()
  286. socket = await ssh.connectStream(streamEndpointSchema.parse(prepared.streams.terminal), signal)
  287. signal.throwIfAborted()
  288. socket.end()
  289. const output = new PassThrough()
  290. socket.pipe(output)
  291. const started = await ssh.request('process.start', { id }, z.object({ pid: z.number().int().positive() }).strict(), signal)
  292. signal.throwIfAborted()
  293. const done = ssh.request('process.done', { id }, doneSchema, undefined, true).then(result => ({ exitCode: result.outcome.exitCode, signal: result.outcome.signal as NodeJS.Signals | null }))
  294. void done.catch(() => {})
  295. let closing: Promise<void> | undefined
  296. const abort = (): void => { void handle.terminate().catch(() => { void ssh.dispose().catch(() => {}) }) }
  297. const handle: SubprocessTerminalHandle = {
  298. pid: started.pid, output, done,
  299. resize: async (cols, rows) => { await ssh.request('terminal.resize', { id, cols, rows }, z.null()) },
  300. write: async (data) => { await ssh.request('terminal.write', { id, value: data }, z.null()) },
  301. inspectForeground: async () => await ssh.request('terminal.inspect', { id }, foregroundSchema) ?? undefined,
  302. signalForeground: (signal: SubprocessTerminalSignal) => ssh.request('terminal.signal', { id, value: signal }, z.number().int().positive()),
  303. terminate: () => {
  304. closing ??= ssh.request('process.terminate', { id }, z.null(), undefined, true).then(() => {
  305. socket?.destroy()
  306. output.destroy()
  307. signal.removeEventListener('abort', abort)
  308. this.terminals.delete(handle)
  309. }).catch((error: unknown) => { closing = undefined; throw error })
  310. return closing
  311. },
  312. }
  313. signal.addEventListener('abort', abort, { once: true })
  314. this.terminals.add(handle)
  315. return handle
  316. } catch (error) {
  317. socket?.destroy()
  318. try { await ssh.request('process.terminate', { id }, z.null()) } catch (cleanupError) {
  319. void ssh.dispose().catch(() => {})
  320. throw new RemoteCleanupError([error, cleanupError], 'SSH terminal allocation failed and remote cleanup is unknown')
  321. }
  322. throw error
  323. }
  324. }
  325. }
  326. export default SshSubprocessRuntime