host-process.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  1. /** Node-compatible child lifecycle and streaming custom-protocol carrier. */
  2. import { spawn, type ChildProcess } from 'node:child_process'
  3. import { once } from 'node:events'
  4. import { join } from 'node:path'
  5. import { Readable, Writable } from 'node:stream'
  6. import {
  7. DESKTOP_HOST_PROTOCOL_VERSION,
  8. DESKTOP_PIPE_CHUNK_BYTES,
  9. DESKTOP_REQUEST_PIPE_FD,
  10. DESKTOP_RESPONSE_PIPE_FD,
  11. DesktopHostResponseDecoder,
  12. encodeDesktopRequestCancel,
  13. encodeDesktopRequestData,
  14. encodeDesktopRequestEnd,
  15. encodeDesktopRequestStart,
  16. type DesktopHostCommand,
  17. type DesktopHostEvent,
  18. type DesktopHostResponseFrame,
  19. } from './host-protocol.ts'
  20. interface PendingResponse {
  21. readonly resolve: (response: Response) => void
  22. readonly reject: (error: Error) => void
  23. responseStarted: boolean
  24. uploadOpen: boolean
  25. controller?: ReadableStreamDefaultController<Uint8Array>
  26. requestReader?: ReadableStreamDefaultReader<Uint8Array>
  27. removeAbort?: () => void
  28. }
  29. function isDesktopHostEvent(message: unknown): message is DesktopHostEvent {
  30. if (typeof message !== 'object' || message === null || !('type' in message)) return false
  31. const candidate = message as Record<string, unknown>
  32. switch (candidate.type) {
  33. case 'ready':
  34. return candidate.protocolVersion === DESKTOP_HOST_PROTOCOL_VERSION && typeof candidate.dshVersion === 'string'
  35. case 'fatal':
  36. return typeof candidate.message === 'string'
  37. default:
  38. return false
  39. }
  40. }
  41. function errorOf(reason: unknown, fallback: string): Error {
  42. return reason instanceof Error ? reason : new Error(fallback)
  43. }
  44. async function exitsWithin(exit: Promise<void>, milliseconds: number): Promise<boolean> {
  45. let timer: ReturnType<typeof setTimeout> | undefined
  46. const timeout = new Promise<false>((resolve) => {
  47. timer = setTimeout(() => { resolve(false) }, milliseconds)
  48. timer.unref()
  49. })
  50. try {
  51. return await Promise.race([exit.then(() => true), timeout])
  52. } finally {
  53. if (timer !== undefined) clearTimeout(timer)
  54. }
  55. }
  56. /** Ready facts reported by one installed dsh child. */
  57. export interface DesktopHostReady {
  58. readonly protocolVersion: typeof DESKTOP_HOST_PROTOCOL_VERSION
  59. readonly dshVersion: string
  60. }
  61. /** One dsh backend running under an owned Node-compatible executable. */
  62. export class DesktopHostProcess {
  63. private child: ChildProcess | undefined
  64. private requestPipe: Writable | undefined
  65. private responsePipe: Readable | undefined
  66. private readonly responseDecoder = new DesktopHostResponseDecoder()
  67. private requestWriteTail: Promise<void> = Promise.resolve()
  68. private nextStreamId = 1
  69. private readonly pending = new Map<number, PendingResponse>()
  70. private readonly blockedResponses = new Set<number>()
  71. private readyResolve!: (ready: DesktopHostReady) => void
  72. private readyReject!: (error: Error) => void
  73. private readonly readyPromise = new Promise<DesktopHostReady>((resolve, reject) => {
  74. this.readyResolve = resolve
  75. this.readyReject = reject
  76. })
  77. private exitPromise: Promise<void> | undefined
  78. private stderr = ''
  79. private failureReported = false
  80. /**
  81. * @param executable - absolute upstream Node.js or Electron executable.
  82. * @param runtimeDir - immutable packages carried by the current application.
  83. * @param projectDir - active or staged desktop plugin profile.
  84. * @param inspectPort - optional loopback inspector port for workspace development.
  85. * @param environment - Child environment; runtime and package-manager overrides are removed.
  86. * @param onFailure - Receives the first fatal child or transport failure, including after readiness.
  87. */
  88. constructor(
  89. private readonly executable: string,
  90. private readonly runtimeDir: string,
  91. private readonly projectDir: string,
  92. private readonly inspectPort?: number,
  93. private readonly environment: NodeJS.ProcessEnv = process.env,
  94. private readonly onFailure?: (error: Error) => void,
  95. ) {}
  96. /** Start the child once and resolve only after its complete composition is active. */
  97. async start(): Promise<DesktopHostReady> {
  98. if (this.child !== undefined) return this.readyPromise
  99. const entry = join(this.runtimeDir, 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'lib', 'index.js')
  100. const child = spawn(this.executable, [
  101. ...(this.inspectPort === undefined ? [] : [`--inspect=127.0.0.1:${String(this.inspectPort)}`]),
  102. entry,
  103. this.runtimeDir,
  104. this.projectDir,
  105. ...(this.inspectPort === undefined ? [] : ['--allow-linked-profile']),
  106. ], {
  107. cwd: this.projectDir,
  108. env: {
  109. ...Object.fromEntries(Object.entries(this.environment).filter(([name]) => (
  110. name !== 'NODE_OPTIONS' && name !== 'NODE_PATH' && !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name)
  111. ))),
  112. ELECTRON_RUN_AS_NODE: '1',
  113. },
  114. stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe', 'ipc'],
  115. })
  116. const requestPipe = child.stdio[DESKTOP_REQUEST_PIPE_FD]
  117. const responsePipe = child.stdio[DESKTOP_RESPONSE_PIPE_FD]
  118. if (!(requestPipe instanceof Writable) || !(responsePipe instanceof Readable)) {
  119. child.kill('SIGTERM')
  120. throw new Error('dsh desktop host did not expose the required byte pipes and IPC channel')
  121. }
  122. this.child = child
  123. this.requestPipe = requestPipe
  124. this.responsePipe = responsePipe
  125. child.stderr?.setEncoding('utf8')
  126. child.stderr?.on('data', (chunk: string) => { this.stderr += chunk })
  127. child.stdout?.pipe(process.stdout)
  128. responsePipe.on('data', (chunk: Buffer) => { this.acceptResponseBytes(chunk) })
  129. responsePipe.once('end', () => {
  130. try {
  131. this.responseDecoder.finish()
  132. this.fail(new Error('dsh desktop host response pipe ended'))
  133. } catch (error) {
  134. this.fail(errorOf(error, 'dsh desktop host response pipe failed'))
  135. }
  136. })
  137. requestPipe.once('error', (error) => { this.fail(error) })
  138. responsePipe.once('error', (error) => { this.fail(error) })
  139. child.on('message', (message: unknown) => {
  140. if (!isDesktopHostEvent(message)) {
  141. this.fail(new Error('dsh desktop host sent an invalid IPC event'))
  142. child.kill('SIGTERM')
  143. return
  144. }
  145. this.handleMessage(message)
  146. })
  147. child.once('error', (error) => { this.fail(error) })
  148. this.exitPromise = new Promise<void>((resolve) => {
  149. child.once('close', (code) => {
  150. const suffix = this.stderr.trim() === '' ? '' : `: ${this.stderr.trim()}`
  151. if (code !== 0 && code !== null) this.fail(new Error(`dsh desktop host exited with ${String(code)}${suffix}`))
  152. else this.fail(new Error(`dsh desktop host stopped${suffix}`))
  153. resolve()
  154. })
  155. })
  156. return this.readyPromise
  157. }
  158. /** Forward one `dsh-app://app` request to the child without buffering its body. */
  159. async fetch(request: Request): Promise<Response> {
  160. await this.start()
  161. const child = this.child
  162. if (child === undefined || !child.connected || this.requestPipe === undefined) {
  163. throw new Error('dsh desktop host is unavailable')
  164. }
  165. if (this.nextStreamId > 0xffff_ffff) throw new Error('dsh desktop host exhausted its request stream ids')
  166. const streamId = this.nextStreamId++
  167. const method = request.method.toUpperCase()
  168. const hasBody = method !== 'GET' && method !== 'HEAD' && request.body !== null
  169. return new Promise<Response>((resolve, reject) => {
  170. const pending: PendingResponse = {
  171. resolve,
  172. reject,
  173. responseStarted: false,
  174. uploadOpen: hasBody,
  175. }
  176. const abort = (): void => {
  177. if (!this.pending.has(streamId)) return
  178. const error = errorOf(request.signal.reason, 'request aborted')
  179. pending.uploadOpen = false
  180. void pending.requestReader?.cancel(error).catch(() => undefined)
  181. this.enqueueRequestFrame(encodeDesktopRequestCancel(streamId)).catch((pipeError: unknown) => {
  182. this.fail(errorOf(pipeError, 'dsh desktop request pipe failed'))
  183. })
  184. if (pending.controller === undefined) pending.reject(error)
  185. else pending.controller.error(error)
  186. this.finishPending(streamId, false)
  187. }
  188. if (request.signal.aborted) {
  189. reject(errorOf(request.signal.reason, 'request aborted'))
  190. return
  191. }
  192. request.signal.addEventListener('abort', abort, { once: true })
  193. pending.removeAbort = () => { request.signal.removeEventListener('abort', abort) }
  194. this.pending.set(streamId, pending)
  195. this.pumpRequest(streamId, request, hasBody).catch((error: unknown) => {
  196. this.failPending(streamId, errorOf(error, 'dsh desktop request upload failed'))
  197. })
  198. })
  199. }
  200. /** Request graceful teardown, then wait for child exit. */
  201. async stop(): Promise<void> {
  202. const child = this.child
  203. if (child === undefined) return
  204. this.blockedResponses.clear()
  205. this.responsePipe?.resume()
  206. if (child.connected) this.send({ type: 'shutdown' })
  207. // Closing the parent-owned write end releases the Host's pending Windows pipe read.
  208. this.requestPipe?.destroy()
  209. const exited = this.exitPromise ?? Promise.resolve()
  210. if (!await exitsWithin(exited, 10_000)) child.kill('SIGTERM')
  211. if (!await exitsWithin(exited, 5_000)) {
  212. child.kill('SIGKILL')
  213. if (!await exitsWithin(exited, 5_000)) {
  214. throw new Error('dsh desktop host did not exit after SIGKILL')
  215. }
  216. }
  217. this.child = undefined
  218. this.requestPipe = undefined
  219. this.responsePipe = undefined
  220. }
  221. private async pumpRequest(streamId: number, request: Request, hasBody: boolean): Promise<void> {
  222. await this.enqueueRequestFrame(encodeDesktopRequestStart(streamId, {
  223. url: request.url,
  224. method: request.method.toUpperCase(),
  225. headers: [...request.headers.entries()],
  226. hasBody,
  227. }))
  228. if (!hasBody) return
  229. const body = request.body
  230. if (body === null) throw new Error('dsh desktop request body disappeared before upload')
  231. const reader = body.getReader()
  232. const pending = this.pending.get(streamId)
  233. if (pending === undefined) {
  234. await reader.cancel()
  235. return
  236. }
  237. pending.requestReader = reader
  238. try {
  239. for (;;) {
  240. const next = await reader.read()
  241. if (next.done) break
  242. for (let offset = 0; offset < next.value.byteLength; offset += DESKTOP_PIPE_CHUNK_BYTES) {
  243. if (!this.pending.has(streamId)) return
  244. await this.enqueueRequestFrame(encodeDesktopRequestData(
  245. streamId,
  246. next.value.subarray(offset, offset + DESKTOP_PIPE_CHUNK_BYTES),
  247. ))
  248. }
  249. }
  250. const live = this.pending.get(streamId)
  251. if (live !== undefined) {
  252. await this.enqueueRequestFrame(encodeDesktopRequestEnd(streamId))
  253. live.uploadOpen = false
  254. }
  255. } finally {
  256. reader.releaseLock()
  257. const live = this.pending.get(streamId)
  258. if (live?.requestReader === reader) delete live.requestReader
  259. }
  260. }
  261. private enqueueRequestFrame(frame: Buffer): Promise<void> {
  262. const write = this.requestWriteTail.then(async () => {
  263. const pipe = this.requestPipe
  264. if (pipe === undefined || pipe.destroyed) throw new Error('dsh desktop host request pipe is unavailable')
  265. if (!pipe.write(frame)) await once(pipe, 'drain')
  266. })
  267. this.requestWriteTail = write.catch(() => undefined)
  268. return write
  269. }
  270. private send(message: DesktopHostCommand): void {
  271. const child = this.child
  272. if (child === undefined || !child.connected) throw new Error('dsh desktop host IPC is unavailable')
  273. child.send(message, (error) => { if (error !== null) this.fail(error) })
  274. }
  275. private acceptResponseBytes(chunk: Buffer): void {
  276. try {
  277. for (const frame of this.responseDecoder.push(chunk)) this.handleResponseFrame(frame)
  278. } catch (error) {
  279. this.fail(errorOf(error, 'dsh desktop host response pipe failed'))
  280. this.child?.kill('SIGTERM')
  281. }
  282. }
  283. private handleResponseFrame(frame: DesktopHostResponseFrame): void {
  284. const pending = this.pending.get(frame.streamId)
  285. if (pending === undefined) {
  286. if (frame.streamId >= this.nextStreamId) {
  287. throw new Error(`dsh desktop host responded for unknown stream ${String(frame.streamId)}`)
  288. }
  289. return
  290. }
  291. switch (frame.type) {
  292. case 'start': {
  293. if (pending.responseStarted) throw new Error(`dsh desktop host started stream ${String(frame.streamId)} twice`)
  294. pending.responseStarted = true
  295. let body: ReadableStream<Uint8Array> | null = null
  296. if (frame.hasBody) {
  297. body = new ReadableStream<Uint8Array>({
  298. start: (controller) => { pending.controller = controller },
  299. pull: () => {
  300. this.blockedResponses.delete(frame.streamId)
  301. this.resumeResponsePipe()
  302. },
  303. cancel: (reason) => { this.cancelResponse(frame.streamId, reason) },
  304. })
  305. }
  306. pending.resolve(new Response(body, {
  307. status: frame.status,
  308. headers: new Headers(frame.headers.map(([name, value]) => [name, value] as [string, string])),
  309. }))
  310. return
  311. }
  312. case 'data': {
  313. const controller = pending.controller
  314. if (!pending.responseStarted || controller === undefined) {
  315. throw new Error(`dsh desktop host sent body data before a body start for stream ${String(frame.streamId)}`)
  316. }
  317. controller.enqueue(frame.data)
  318. if ((controller.desiredSize ?? 0) <= 0) {
  319. this.blockedResponses.add(frame.streamId)
  320. this.responsePipe?.pause()
  321. }
  322. return
  323. }
  324. case 'end':
  325. if (!pending.responseStarted) {
  326. throw new Error(`dsh desktop host ended stream ${String(frame.streamId)} before its response start`)
  327. }
  328. pending.controller?.close()
  329. this.finishPending(frame.streamId, true)
  330. return
  331. case 'error':
  332. this.failPending(frame.streamId, new Error(frame.message))
  333. return
  334. default:
  335. frame satisfies never
  336. }
  337. }
  338. private cancelResponse(streamId: number, reason: unknown): void {
  339. const pending = this.pending.get(streamId)
  340. if (pending === undefined) return
  341. pending.uploadOpen = false
  342. void pending.requestReader?.cancel(reason).catch(() => undefined)
  343. this.enqueueRequestFrame(encodeDesktopRequestCancel(streamId)).catch((error: unknown) => {
  344. this.fail(errorOf(error, 'dsh desktop request pipe failed'))
  345. })
  346. this.finishPending(streamId, false)
  347. }
  348. private failPending(streamId: number, error: Error): void {
  349. const pending = this.pending.get(streamId)
  350. if (pending === undefined) return
  351. pending.uploadOpen = false
  352. void pending.requestReader?.cancel(error).catch(() => undefined)
  353. if (pending.controller === undefined) pending.reject(error)
  354. else pending.controller.error(error)
  355. this.enqueueRequestFrame(encodeDesktopRequestCancel(streamId)).catch((pipeError: unknown) => {
  356. this.fail(errorOf(pipeError, 'dsh desktop request pipe failed'))
  357. })
  358. this.finishPending(streamId, false)
  359. }
  360. private finishPending(streamId: number, cancelOpenUpload: boolean): void {
  361. const pending = this.pending.get(streamId)
  362. if (pending === undefined) return
  363. if (cancelOpenUpload && pending.uploadOpen) {
  364. pending.uploadOpen = false
  365. void pending.requestReader?.cancel().catch(() => undefined)
  366. this.enqueueRequestFrame(encodeDesktopRequestCancel(streamId)).catch((error: unknown) => {
  367. this.fail(errorOf(error, 'dsh desktop request pipe failed'))
  368. })
  369. }
  370. pending.removeAbort?.()
  371. this.pending.delete(streamId)
  372. this.blockedResponses.delete(streamId)
  373. this.resumeResponsePipe()
  374. }
  375. private resumeResponsePipe(): void {
  376. if (this.blockedResponses.size === 0) this.responsePipe?.resume()
  377. }
  378. private handleMessage(message: DesktopHostEvent): void {
  379. switch (message.type) {
  380. case 'ready':
  381. this.readyResolve(message)
  382. return
  383. case 'fatal':
  384. this.fail(new Error(message.message))
  385. return
  386. default:
  387. message satisfies never
  388. }
  389. }
  390. private fail(error: Error): void {
  391. this.readyReject(error)
  392. if (!this.failureReported) {
  393. this.failureReported = true
  394. try { this.onFailure?.(error) } catch (listenerError) {
  395. console.error('desktop host failure listener failed', listenerError)
  396. }
  397. }
  398. for (const pending of this.pending.values()) {
  399. void pending.requestReader?.cancel(error).catch(() => undefined)
  400. if (pending.controller === undefined) pending.reject(error)
  401. else pending.controller.error(error)
  402. pending.removeAbort?.()
  403. }
  404. this.pending.clear()
  405. this.blockedResponses.clear()
  406. this.responsePipe?.resume()
  407. }
  408. }