host-process.spec.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { afterEach, describe, expect, it } 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('carries raw request and response bytes and shuts the child down cleanly', async () => {
  74. const project = projectWithHost(`
  75. const bodies = new Map()
  76. process.send({ type: 'ready', protocolVersion: 3, dshVersion: process.env.NODE_OPTIONS ?? 'clean' })
  77. function onRequestFrame(frame) {
  78. if (frame.type === 1) {
  79. const request = JSON.parse(frame.payload)
  80. bodies.set(frame.streamId, Buffer.alloc(0))
  81. if (!request.hasBody) answer(frame.streamId)
  82. } else if (frame.type === 2) {
  83. bodies.set(frame.streamId, Buffer.concat([bodies.get(frame.streamId), frame.payload]))
  84. } else if (frame.type === 3) {
  85. answer(frame.streamId)
  86. }
  87. }
  88. function answer(streamId) {
  89. responseStart(streamId, { headers: [['content-type', 'text/plain']] })
  90. responseData(streamId, Buffer.concat([Buffer.from('desktop:'), bodies.get(streamId)]))
  91. responseEnd(streamId)
  92. }
  93. `)
  94. const previous = process.env.NODE_OPTIONS
  95. process.env.NODE_OPTIONS = '--require /path/that-must-not-reach-the-child'
  96. const host = new DesktopHostProcess(process.execPath, project)
  97. try {
  98. await expect(host.start()).resolves.toMatchObject({ dshVersion: 'clean' })
  99. const response = await host.fetch(new Request('dsh-app://app/example', { method: 'POST', body: 'request' }))
  100. expect(response.status).toBe(200)
  101. await expect(response.text()).resolves.toBe('desktop:request')
  102. await expect(host.stop()).resolves.toBeUndefined()
  103. } finally {
  104. if (previous === undefined) delete process.env.NODE_OPTIONS
  105. else process.env.NODE_OPTIONS = previous
  106. await host.stop().catch(() => undefined)
  107. }
  108. })
  109. it('streams a large binary response in bounded raw frames', async () => {
  110. const size = 2 * 1024 * 1024
  111. const project = projectWithHost(`
  112. process.send({ type: 'ready', protocolVersion: 3, dshVersion: 'large-response' })
  113. function onRequestFrame(frame) {
  114. if (frame.type !== 1) return
  115. responseStart(frame.streamId)
  116. const bytes = Buffer.alloc(${String(64 * 1024)}, 97)
  117. for (let offset = 0; offset < ${String(size)}; offset += bytes.length) responseData(frame.streamId, bytes)
  118. responseEnd(frame.streamId)
  119. }
  120. `)
  121. const host = new DesktopHostProcess(process.execPath, project)
  122. try {
  123. const response = await host.fetch(new Request('dsh-app://app/large'))
  124. const body = new Uint8Array(await response.arrayBuffer())
  125. expect(body).toHaveLength(size)
  126. expect(body[0]).toBe(97)
  127. expect(body.at(-1)).toBe(97)
  128. } finally {
  129. await host.stop().catch(() => undefined)
  130. }
  131. })
  132. it('stops an unfinished upload when the Host completes its response early', async () => {
  133. const project = projectWithHost(`
  134. process.send({ type: 'ready', protocolVersion: 3, dshVersion: 'early-response' })
  135. function onRequestFrame(frame) {
  136. if (frame.type !== 2) return
  137. responseStart(frame.streamId)
  138. responseData(frame.streamId, 'accepted')
  139. responseEnd(frame.streamId)
  140. }
  141. `)
  142. let canceled = false
  143. const body = new ReadableStream<Uint8Array>({
  144. start(controller) { controller.enqueue(Buffer.from('first')) },
  145. cancel() { canceled = true },
  146. })
  147. const host = new DesktopHostProcess(process.execPath, project)
  148. try {
  149. const request = new Request('dsh-app://app/early', {
  150. method: 'POST',
  151. body,
  152. duplex: 'half',
  153. } as RequestInit & { duplex: 'half' })
  154. const response = await host.fetch(request)
  155. await expect(response.text()).resolves.toBe('accepted')
  156. await expect.poll(() => canceled).toBe(true)
  157. } finally {
  158. await host.stop().catch(() => undefined)
  159. }
  160. })
  161. it('ignores a response end that arrives after the renderer cancels its stream', async () => {
  162. const project = projectWithHost(`
  163. process.send({ type: 'ready', protocolVersion: 3, dshVersion: 'cancel-race' })
  164. const urls = new Map()
  165. function onRequestFrame(frame) {
  166. if (frame.type === 1) {
  167. const request = JSON.parse(frame.payload)
  168. urls.set(frame.streamId, request.url)
  169. responseStart(frame.streamId)
  170. if (request.url.endsWith('/after')) {
  171. responseData(frame.streamId, 'alive')
  172. responseEnd(frame.streamId)
  173. }
  174. } else if (frame.type === 4 && urls.get(frame.streamId).endsWith('/cancel')) {
  175. responseEnd(frame.streamId)
  176. }
  177. }
  178. `)
  179. const host = new DesktopHostProcess(process.execPath, project)
  180. try {
  181. const canceled = await host.fetch(new Request('dsh-app://app/cancel'))
  182. await canceled.body?.cancel()
  183. await new Promise(resolve => setTimeout(resolve, 25))
  184. const after = await host.fetch(new Request('dsh-app://app/after'))
  185. await expect(after.text()).resolves.toBe('alive')
  186. } finally {
  187. await host.stop().catch(() => undefined)
  188. }
  189. })
  190. it('rejects invalid response framing and a clean exit before readiness', async () => {
  191. const invalid = new DesktopHostProcess(process.execPath, projectWithHost(`
  192. process.send({ type: 'ready', protocolVersion: 3, dshVersion: 'invalid-frame' })
  193. function onRequestFrame(frame) {
  194. if (frame.type === 1) responsePipe.write(Buffer.alloc(13))
  195. }
  196. `))
  197. await invalid.start()
  198. await expect(invalid.fetch(new Request('dsh-app://app/invalid'))).rejects.toThrow(/invalid Host response frame marker/u)
  199. await invalid.stop().catch(() => undefined)
  200. const earlyExit = new DesktopHostProcess(process.execPath, projectWithHost(`
  201. function onRequestFrame() {}
  202. process.exit(0)
  203. `))
  204. await expect(earlyExit.start()).rejects.toThrow(/response pipe ended/u)
  205. })
  206. })