host-process.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { afterEach, describe, expect, it, vi } from 'vitest'
  5. import { DesktopHostProcess } from '../src/host-process.ts'
  6. const roots: string[] = []
  7. const HOST_WIRE = `
  8. import { closeSync, createReadStream, createWriteStream } from 'node:fs'
  9. const requestPipe = createReadStream('', { fd: 3, autoClose: false })
  10. const responsePipe = createWriteStream('', { fd: 4, autoClose: false })
  11. const MAGIC = 0x44534833
  12. const HEADER = 13
  13. function responseFrame(type, streamId, payload = Buffer.alloc(0)) {
  14. const frame = Buffer.allocUnsafe(HEADER + payload.length)
  15. frame.writeUInt32BE(MAGIC, 0)
  16. frame.writeUInt8(type, 4)
  17. frame.writeUInt32BE(streamId, 5)
  18. frame.writeUInt32BE(payload.length, 9)
  19. payload.copy(frame, HEADER)
  20. return frame
  21. }
  22. function responseStart(streamId, options = {}) {
  23. const value = { status: options.status ?? 200, headers: options.headers ?? [], hasBody: options.hasBody ?? true }
  24. responsePipe.write(responseFrame(1, streamId, Buffer.from(JSON.stringify(value))))
  25. }
  26. function responseData(streamId, data) {
  27. responsePipe.write(responseFrame(2, streamId, Buffer.from(data)))
  28. }
  29. function responseEnd(streamId) { responsePipe.write(responseFrame(3, streamId)) }
  30. function responseError(streamId, message) {
  31. responsePipe.write(responseFrame(4, streamId, Buffer.from(JSON.stringify({ message }))))
  32. }
  33. let requestBuffer = Buffer.alloc(0)
  34. requestPipe.on('data', chunk => {
  35. requestBuffer = requestBuffer.length === 0 ? chunk : Buffer.concat([requestBuffer, chunk])
  36. while (requestBuffer.length >= HEADER) {
  37. if (requestBuffer.readUInt32BE(0) !== MAGIC) throw new Error('invalid request marker')
  38. const type = requestBuffer.readUInt8(4)
  39. const streamId = requestBuffer.readUInt32BE(5)
  40. const length = requestBuffer.readUInt32BE(9)
  41. if (requestBuffer.length < HEADER + length) return
  42. const payload = requestBuffer.subarray(HEADER, HEADER + length)
  43. requestBuffer = requestBuffer.subarray(HEADER + length)
  44. onRequestFrame({ type, streamId, payload })
  45. }
  46. })
  47. process.on('message', message => {
  48. if (message.type === 'shutdown') {
  49. requestPipe.destroy()
  50. closeSync(3)
  51. responsePipe.end(() => {
  52. responsePipe.destroy()
  53. closeSync(4)
  54. process.disconnect()
  55. process.exitCode = 0
  56. })
  57. }
  58. })
  59. `
  60. function projectWithHost(source: string): string {
  61. const project = mkdtempSync(join(tmpdir(), 'dsh-desktop-host-test-'))
  62. roots.push(project)
  63. const packageRoot = join(project, 'node_modules', '@deepseek-ai', 'dsh-desktop-host')
  64. mkdirSync(join(packageRoot, 'lib'), { recursive: true })
  65. writeFileSync(join(packageRoot, 'package.json'), '{"name":"@deepseek-ai/dsh-desktop-host","type":"module"}\n')
  66. writeFileSync(join(packageRoot, 'lib', 'index.js'), `${HOST_WIRE}\n${source}`)
  67. return project
  68. }
  69. afterEach(() => {
  70. for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
  71. })
  72. describe('desktop host process', () => {
  73. it('reports a fatal event after readiness once and stops the child', async () => {
  74. const runtime = projectWithHost(`
  75. process.send({ type: 'ready', protocolVersion: 3, dshVersion: '1.0.0' })
  76. function onRequestFrame(frame) {
  77. if (frame.type === 1) process.send({ type: 'fatal', message: 'plugin unavailable' })
  78. }
  79. `)
  80. const failure = vi.fn()
  81. const host = new DesktopHostProcess(process.execPath, runtime, runtime, undefined, process.env, failure)
  82. try {
  83. await host.start()
  84. await expect(host.fetch(new Request('dsh-app://app/'))).rejects.toThrow('plugin unavailable')
  85. await host.stop()
  86. expect(failure).toHaveBeenCalledTimes(1)
  87. expect(failure).toHaveBeenCalledWith(new Error('plugin unavailable'))
  88. } finally { await host.stop() }
  89. })
  90. it('settles teardown when the executable cannot be spawned', async () => {
  91. const runtime = projectWithHost('function onRequestFrame() {}')
  92. const host = new DesktopHostProcess(join(runtime, 'missing-node'), runtime, runtime)
  93. try { await expect(host.start()).rejects.toThrow() } finally { await host.stop() }
  94. })
  95. it('loads the resource entry with a separate profile and scrubs Node resolution overrides', async () => {
  96. const runtime = projectWithHost(`
  97. process.send({ type: 'ready', protocolVersion: 3, dshVersion: 'split-runtime' })
  98. function onRequestFrame(frame) {
  99. if (frame.type !== 1) return
  100. responseStart(frame.streamId)
  101. responseData(frame.streamId, JSON.stringify({runtime: process.argv[2], profile: process.argv[3], cwd: process.cwd(), nodePath: process.env.NODE_PATH}))
  102. responseEnd(frame.streamId)
  103. }
  104. `)
  105. const profile = mkdtempSync(join(tmpdir(), 'desktop-external-profile-'))
  106. roots.push(profile)
  107. const host = new DesktopHostProcess(process.execPath, runtime, profile, undefined, {
  108. ...process.env, NODE_OPTIONS: '--invalid-desktop-test-option', NODE_PATH: '/unowned',
  109. })
  110. try {
  111. const response = await host.fetch(new Request('dsh-app://app/environment'))
  112. expect(await response.json()).toEqual({ runtime, profile, cwd: realpathSync(profile) })
  113. } finally { await host.stop() }
  114. })
  115. it('carries raw request and response bytes and shuts the child down cleanly', async () => {
  116. const project = projectWithHost(`
  117. const bodies = new Map()
  118. process.send({ type: 'ready', protocolVersion: 3, dshVersion: process.env.NODE_OPTIONS ?? 'clean' })
  119. function onRequestFrame(frame) {
  120. if (frame.type === 1) {
  121. const request = JSON.parse(frame.payload)
  122. bodies.set(frame.streamId, Buffer.alloc(0))
  123. if (!request.hasBody) answer(frame.streamId)
  124. } else if (frame.type === 2) {
  125. bodies.set(frame.streamId, Buffer.concat([bodies.get(frame.streamId), frame.payload]))
  126. } else if (frame.type === 3) {
  127. answer(frame.streamId)
  128. }
  129. }
  130. function answer(streamId) {
  131. responseStart(streamId, { headers: [['content-type', 'text/plain']] })
  132. responseData(streamId, Buffer.concat([Buffer.from('desktop:'), bodies.get(streamId)]))
  133. responseEnd(streamId)
  134. }
  135. `)
  136. const previous = process.env.NODE_OPTIONS
  137. process.env.NODE_OPTIONS = '--require /path/that-must-not-reach-the-child'
  138. const host = new DesktopHostProcess(process.execPath, project, project)
  139. try {
  140. await expect(host.start()).resolves.toMatchObject({ dshVersion: 'clean' })
  141. const response = await host.fetch(new Request('dsh-app://app/example', { method: 'POST', body: 'request' }))
  142. expect(response.status).toBe(200)
  143. await expect(response.text()).resolves.toBe('desktop:request')
  144. await expect(host.stop()).resolves.toBeUndefined()
  145. } finally {
  146. if (previous === undefined) delete process.env.NODE_OPTIONS
  147. else process.env.NODE_OPTIONS = previous
  148. await host.stop().catch(() => undefined)
  149. }
  150. })
  151. it('streams a large binary response in bounded raw frames', async () => {
  152. const size = 2 * 1024 * 1024
  153. const project = projectWithHost(`
  154. process.send({ type: 'ready', protocolVersion: 3, dshVersion: 'large-response' })
  155. function onRequestFrame(frame) {
  156. if (frame.type !== 1) return
  157. responseStart(frame.streamId)
  158. const bytes = Buffer.alloc(${String(64 * 1024)}, 97)
  159. for (let offset = 0; offset < ${String(size)}; offset += bytes.length) responseData(frame.streamId, bytes)
  160. responseEnd(frame.streamId)
  161. }
  162. `)
  163. const host = new DesktopHostProcess(process.execPath, project, project)
  164. try {
  165. const response = await host.fetch(new Request('dsh-app://app/large'))
  166. const body = new Uint8Array(await response.arrayBuffer())
  167. expect(body).toHaveLength(size)
  168. expect(body[0]).toBe(97)
  169. expect(body.at(-1)).toBe(97)
  170. } finally {
  171. await host.stop().catch(() => undefined)
  172. }
  173. })
  174. it('stops an unfinished upload when the Host completes its response early', async () => {
  175. const project = projectWithHost(`
  176. process.send({ type: 'ready', protocolVersion: 3, dshVersion: 'early-response' })
  177. function onRequestFrame(frame) {
  178. if (frame.type !== 2) return
  179. responseStart(frame.streamId)
  180. responseData(frame.streamId, 'accepted')
  181. responseEnd(frame.streamId)
  182. }
  183. `)
  184. let canceled = false
  185. const body = new ReadableStream<Uint8Array>({
  186. start(controller) { controller.enqueue(Buffer.from('first')) },
  187. cancel() { canceled = true },
  188. })
  189. const host = new DesktopHostProcess(process.execPath, project, project)
  190. try {
  191. const request = new Request('dsh-app://app/early', {
  192. method: 'POST',
  193. body,
  194. duplex: 'half',
  195. } as RequestInit & { duplex: 'half' })
  196. const response = await host.fetch(request)
  197. await expect(response.text()).resolves.toBe('accepted')
  198. await expect.poll(() => canceled).toBe(true)
  199. } finally {
  200. await host.stop().catch(() => undefined)
  201. }
  202. })
  203. it('ignores a response end that arrives after the renderer cancels its stream', async () => {
  204. const project = projectWithHost(`
  205. process.send({ type: 'ready', protocolVersion: 3, dshVersion: 'cancel-race' })
  206. const urls = new Map()
  207. function onRequestFrame(frame) {
  208. if (frame.type === 1) {
  209. const request = JSON.parse(frame.payload)
  210. urls.set(frame.streamId, request.url)
  211. responseStart(frame.streamId)
  212. if (request.url.endsWith('/after')) {
  213. responseData(frame.streamId, 'alive')
  214. responseEnd(frame.streamId)
  215. }
  216. } else if (frame.type === 4 && urls.get(frame.streamId).endsWith('/cancel')) {
  217. responseEnd(frame.streamId)
  218. }
  219. }
  220. `)
  221. const host = new DesktopHostProcess(process.execPath, project, project)
  222. try {
  223. const canceled = await host.fetch(new Request('dsh-app://app/cancel'))
  224. await canceled.body?.cancel()
  225. await new Promise(resolve => setTimeout(resolve, 25))
  226. const after = await host.fetch(new Request('dsh-app://app/after'))
  227. await expect(after.text()).resolves.toBe('alive')
  228. } finally {
  229. await host.stop().catch(() => undefined)
  230. }
  231. })
  232. it('rejects invalid response framing and a clean exit before readiness', async () => {
  233. const invalid = new DesktopHostProcess(process.execPath, projectWithHost(`
  234. process.send({ type: 'ready', protocolVersion: 3, dshVersion: 'invalid-frame' })
  235. function onRequestFrame(frame) {
  236. if (frame.type === 1) responsePipe.write(Buffer.alloc(13))
  237. }
  238. `), projectWithHost(''))
  239. await invalid.start()
  240. await expect(invalid.fetch(new Request('dsh-app://app/invalid'))).rejects.toThrow(/invalid Host response frame marker/u)
  241. await invalid.stop().catch(() => undefined)
  242. const earlyExit = new DesktopHostProcess(process.execPath, projectWithHost(`
  243. function onRequestFrame() {}
  244. process.exit(0)
  245. `), projectWithHost(''))
  246. await expect(earlyExit.start()).rejects.toThrow(/response pipe ended/u)
  247. })
  248. })