1
0

boot-write-failure.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. import { EventEmitter } from 'node:events'
  2. import { existsSync } from 'node:fs'
  3. import { dirname } from 'node:path'
  4. import { PassThrough } from 'node:stream'
  5. import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
  6. import { Context } from '@deepseek-ai/cordis'
  7. /**
  8. * Mocked subprocess pipes control synchronous write failures and backpressure
  9. * transitions independently of kernel buffering. The real-subprocess suite
  10. * remains in runtime.spec.ts.
  11. */
  12. const { execFileSyncMock, spawnMock } = vi.hoisted(() => ({ execFileSyncMock: vi.fn(), spawnMock: vi.fn() }))
  13. vi.mock('node:child_process', async (importOriginal) => {
  14. const original = await importOriginal<typeof import('node:child_process')>()
  15. execFileSyncMock.mockImplementation(original.execFileSync)
  16. return { ...original, execFileSync: execFileSyncMock, spawn: spawnMock }
  17. })
  18. const { PythonCodeRuntime } = await import('../src/index.ts')
  19. /** A `child_process.ChildProcess` stand-in whose fd-3 pipe rejects every write. */
  20. function fakeChildWithThrowingFd3(): EventEmitter {
  21. const child = new EventEmitter() as EventEmitter & {
  22. pid?: number
  23. stdout: PassThrough
  24. stderr: PassThrough
  25. stdio: unknown[]
  26. }
  27. // Leave `pid` absent: `finish()` still runs its `clearTimeout(wallTimer)` /
  28. // `removeEventListener(onAbort)` prologue (the TDZ site) before short-
  29. // circuiting on `child.pid === undefined` to `settle` instead of waiting on a
  30. // `close` this fake never emits, so the run resolves promptly.
  31. child.stdout = new PassThrough()
  32. child.stderr = new PassThrough()
  33. // A duplex whose `write` throws synchronously, standing in for an fd-3 pipe
  34. // that fails the moment the boot frame is issued.
  35. const proto = new PassThrough()
  36. proto.write = () => { throw Object.assign(new Error('EPIPE: broken pipe, write'), { code: 'EPIPE' }) }
  37. child.stdio = [new PassThrough(), child.stdout, child.stderr, proto]
  38. return child
  39. }
  40. afterEach(() => {
  41. execFileSyncMock.mockClear()
  42. spawnMock.mockReset()
  43. })
  44. /** A child that emits an async `error` (an ENOENT-style spawn failure). */
  45. function fakeChildWithAsyncSpawnError(): EventEmitter {
  46. const child = new EventEmitter() as EventEmitter & {
  47. pid?: number
  48. stdout: PassThrough
  49. stderr: PassThrough
  50. stdio: unknown[]
  51. }
  52. child.stdout = new PassThrough()
  53. child.stderr = new PassThrough()
  54. const proto = new PassThrough()
  55. child.stdio = [new PassThrough(), child.stdout, child.stderr, proto]
  56. // `spawn` reports an async failure via the child's `error` event; the run
  57. // settles on it as a worker-exit without waiting for `close`.
  58. setImmediate(() => {
  59. child.emit('error', Object.assign(new Error('ENOENT: no such file or directory, spawn python3'), { code: 'ENOENT' }))
  60. })
  61. return child
  62. }
  63. /** A child whose fd-3 pipe accepts the boot write, then rejects the run write. */
  64. function fakeChildWithAckThenThrowingFd3(): EventEmitter {
  65. const child = new EventEmitter() as EventEmitter & {
  66. pid?: number
  67. stdout: PassThrough
  68. stderr: PassThrough
  69. stdio: unknown[]
  70. }
  71. child.stdout = new PassThrough()
  72. child.stderr = new PassThrough()
  73. const proto = new PassThrough()
  74. let writes = 0
  75. proto.write = () => {
  76. writes += 1
  77. if (writes === 1) return true // The boot frame goes out.
  78. throw Object.assign(new Error('EPIPE: broken pipe, write'), { code: 'EPIPE' })
  79. }
  80. child.stdio = [new PassThrough(), child.stdout, child.stderr, proto]
  81. // Emit the boot-ack after the boot write, so the run-frame write fires and
  82. // hits the throwing pipe.
  83. setImmediate(() => proto.emit('data', Buffer.from('{"type":"boot-ack"}\n')))
  84. return child
  85. }
  86. /**
  87. * A child whose fd-3 pipe backpressures every write and is then destroyed
  88. * while the host waits for `drain`. The reply-drain loop must settle on the
  89. * pipe's `close` (or destroyed state) rather than hanging forever waiting for
  90. * a `drain` that can never arrive. Returns the pipe as well so the test can
  91. * assert the drain wait left no listener behind.
  92. */
  93. function fakeChildBackpressuredThenDestroyed(): { child: EventEmitter; proto: PassThrough } {
  94. const child = new EventEmitter() as EventEmitter & {
  95. pid?: number
  96. stdout: PassThrough
  97. stderr: PassThrough
  98. stdio: unknown[]
  99. }
  100. child.stdout = new PassThrough()
  101. child.stderr = new PassThrough()
  102. const proto = new PassThrough()
  103. // Every write reports backpressure (never a `drain` event): the only way the
  104. // reply drain can proceed is the pipe being destroyed under it.
  105. proto.write = () => false
  106. child.stdio = [new PassThrough(), child.stdout, child.stderr, proto]
  107. // Boot-ack → run frame → two binding calls whose replies backpressure, then
  108. // destroy the pipe while the host still waits for `drain`: the drain loop
  109. // resumes with a queued reply left and must break on the destroyed pipe.
  110. setImmediate(() => {
  111. proto.emit('data', Buffer.from('{"type":"boot-ack"}\n'))
  112. setImmediate(() => {
  113. proto.emit('data', Buffer.from('{"type":"call","id":0,"global":"tools","name":"f","args":[]}\n'))
  114. proto.emit('data', Buffer.from('{"type":"call","id":1,"global":"tools","name":"f","args":[]}\n'))
  115. setImmediate(() => proto.destroy())
  116. })
  117. })
  118. return { child, proto }
  119. }
  120. describe('PythonCodeRuntime — controlled subprocess pipes', () => {
  121. it('preserves pending replies across compaction while the pipe stays backpressured', async () => {
  122. const spawned = Promise.withResolvers<undefined>()
  123. let written = Promise.withResolvers<undefined>()
  124. const replies: unknown[] = []
  125. const proto = new PassThrough()
  126. const stdout = new PassThrough()
  127. const stderr = new PassThrough()
  128. const stdin = new PassThrough()
  129. const child = Object.assign(new EventEmitter(), { stdout, stderr, stdio: [stdin, stdout, stderr, proto] })
  130. proto.write = (chunk: unknown) => {
  131. const frame = JSON.parse(String(chunk)) as { type: string }
  132. if (frame.type !== 'reply') return true
  133. replies.push(frame)
  134. written.resolve(undefined)
  135. return false
  136. }
  137. spawnMock.mockImplementation(() => { spawned.resolve(undefined); return child })
  138. const ctx = new Context()
  139. const fiber = await ctx.plugin(PythonCodeRuntime)
  140. const runtime = ctx.codeRuntime as InstanceType<typeof PythonCodeRuntime>
  141. const run = runtime.run(runtime.resolve({
  142. program: 'return 1',
  143. bindings: [{ global: 'tools', functions: { echo: async (value: unknown) => value as number } }],
  144. }))
  145. onTestFinished(async () => {
  146. await fiber.dispose()
  147. await run
  148. for (const stream of [stdin, stdout, stderr, proto]) stream.destroy()
  149. })
  150. const calls = (start: number): void => {
  151. proto.emit('data', Buffer.from(Array.from({ length: 512 }, (_, offset) => JSON.stringify({
  152. type: 'call', id: start + offset, global: 'tools', name: 'echo', args: start + offset,
  153. })).join('\n') + '\n'))
  154. }
  155. const drainThrough = async (count: number): Promise<void> => {
  156. while (replies.length < count) {
  157. written = Promise.withResolvers<undefined>()
  158. expect(proto.listenerCount('drain')).toBe(1)
  159. proto.emit('drain')
  160. await written.promise
  161. }
  162. }
  163. await spawned.promise
  164. proto.emit('data', Buffer.from('{"type":"boot-ack"}\n'))
  165. calls(0)
  166. await written.promise
  167. await drainThrough(256)
  168. calls(512)
  169. await drainThrough(768)
  170. calls(1024)
  171. // Each write remains blocked until this fixture emits drain, keeping
  172. // pending replies behind the consumed-prefix compaction at frame 1024.
  173. await drainThrough(1536)
  174. expect(replies).toEqual(Array.from({ length: 1536 }, (_, id) => ({ type: 'reply', id, ok: true, value: id })))
  175. proto.emit('drain')
  176. proto.emit('data', Buffer.from('{"type":"done","value":"done"}\n'))
  177. expect(await run).toMatchObject({ value: 'done' })
  178. expect(proto.listenerCount('drain')).toBe(0)
  179. })
  180. it('force-kills a version probe that exceeds its load-time deadline', async () => {
  181. const ctx = new Context()
  182. const fiber = await ctx.plugin(PythonCodeRuntime)
  183. expect(execFileSyncMock).toHaveBeenCalledWith(
  184. expect.any(String),
  185. expect.arrayContaining(['-I', '-c']),
  186. expect.objectContaining({ timeout: 5_000, killSignal: 'SIGKILL' }),
  187. )
  188. await fiber.dispose()
  189. })
  190. it('resolves a worker-exit when the fd-3 boot write throws (no TDZ ReferenceError)', async () => {
  191. // Before the fix, the boot-write block ran BEFORE `wallTimer`, `onAbort`,
  192. // and `live` were initialized, so its `finish()` (which clears `wallTimer`,
  193. // removes `onAbort`, and — through `settle` — deletes `live`) hit the
  194. // temporal dead zone and threw a ReferenceError. That escaped the Promise
  195. // executor and REJECTED run() instead of resolving the worker-exit the catch
  196. // constructs. This test would see that rejection; the fix makes it resolve.
  197. spawnMock.mockImplementation(() => fakeChildWithThrowingFd3())
  198. const ctx = new Context()
  199. const fiber = await ctx.plugin(PythonCodeRuntime)
  200. const runtime = ctx.codeRuntime as InstanceType<typeof PythonCodeRuntime>
  201. const result = await runtime.run(runtime.resolve({ program: 'return 1', bindings: [] }))
  202. expect(result.error?.kind).toBe('worker-exit')
  203. expect(result.error?.message).toContain('failed to boot python subprocess')
  204. await fiber.dispose()
  205. })
  206. it('resolves a worker-exit and removes the staging dir when spawn throws synchronously', async () => {
  207. // `spawn` can throw same-tick — EMFILE on a descriptor-exhausted host, or a
  208. // libuv-level failure — before the Promise executor and its settlement path
  209. // exist. Left uncaught it rejected run() (the seam permits rejection only for
  210. // misuse) and stranded the staging directory materializePyScripts had just
  211. // written, which only settle() removes. The fix catches it, unlinks the
  212. // directory, and resolves the same `worker-exit` class as an async ENOENT.
  213. //
  214. // Capture THIS run's exact staging dir from the argv the mocked spawn
  215. // received (`['-I', <dir>/bootstrap.py]`) and assert only that path is gone.
  216. // A tmpdir scan — even a set difference against a pre-run snapshot — would
  217. // flake under vitest's forks pool: a sibling worker creating its own
  218. // `dsh-code-runtime-python-*` dir in the window reads as a leak here. Keying
  219. // off our own argv is fully isolated from concurrent staging.
  220. let stagedBootstrap: string | undefined
  221. spawnMock.mockImplementation((_bin: string, args: string[]) => {
  222. stagedBootstrap = args[args.length - 1]
  223. throw Object.assign(new Error('EMFILE: too many open files'), { code: 'EMFILE' })
  224. })
  225. const ctx = new Context()
  226. const fiber = await ctx.plugin(PythonCodeRuntime)
  227. const runtime = ctx.codeRuntime as InstanceType<typeof PythonCodeRuntime>
  228. const result = await runtime.run(runtime.resolve({ program: 'return 1', bindings: [] }))
  229. expect(result.error?.kind).toBe('worker-exit')
  230. expect(result.error?.message).toContain('python spawn error')
  231. expect(stagedBootstrap).toBeDefined()
  232. expect(existsSync(dirname(stagedBootstrap as string))).toBe(false)
  233. await fiber.dispose()
  234. })
  235. it('resolves a worker-exit when the run write after boot-ack throws', async () => {
  236. // The run frame goes out from the boot-ack handler; a pipe that accepts
  237. // the boot frame but rejects the run write must settle the run as a
  238. // worker-exit rather than reject run() or leave it hanging.
  239. spawnMock.mockImplementation(() => fakeChildWithAckThenThrowingFd3())
  240. const ctx = new Context()
  241. const fiber = await ctx.plugin(PythonCodeRuntime)
  242. const runtime = ctx.codeRuntime as InstanceType<typeof PythonCodeRuntime>
  243. const result = await runtime.run(runtime.resolve({ program: 'return 1', bindings: [] }))
  244. expect(result.error?.kind).toBe('worker-exit')
  245. expect(result.error?.message).toContain('failed to boot python subprocess')
  246. await fiber.dispose()
  247. })
  248. it('resolves a worker-exit when spawn reports an async error', async () => {
  249. // A spawn that fails asynchronously (ENOENT for an interpreter removed
  250. // after load, or a libuv-level failure) surfaces through the child's
  251. // `error` event, not a synchronous throw. The run must settle as a
  252. // worker-exit from that event.
  253. spawnMock.mockImplementation(() => fakeChildWithAsyncSpawnError())
  254. const ctx = new Context()
  255. const fiber = await ctx.plugin(PythonCodeRuntime)
  256. const runtime = ctx.codeRuntime as InstanceType<typeof PythonCodeRuntime>
  257. const result = await runtime.run(runtime.resolve({ program: 'return 1', bindings: [] }))
  258. expect(result.error?.kind).toBe('worker-exit')
  259. expect(result.error?.message).toContain('python spawn error')
  260. await fiber.dispose()
  261. })
  262. it('does not hang the reply drain when the pipe is destroyed mid-backpressure', async () => {
  263. // The reply drain waits for `drain` when fd 3's buffer is full. A pipe
  264. // destroyed under that wait never emits `drain` again; the drain must
  265. // settle on `close` instead, or `draining` stays true and the queued reply
  266. // (here a 4 MiB string) is pinned with the closure forever. The fake child
  267. // backpressures every write and destroys fd 3 right after the binding
  268. // call, so the host is mid-drain when the pipe dies. No `done` frame ever
  269. // arrives, so the run settles on the wall clock — the drain wait must have
  270. // removed its listeners by then (a `once('drain')` wait would leave one
  271. // attached to the destroyed pipe forever).
  272. let proto: PassThrough | undefined
  273. spawnMock.mockImplementation(() => {
  274. const fake = fakeChildBackpressuredThenDestroyed()
  275. proto = fake.proto
  276. return fake.child
  277. })
  278. const ctx = new Context()
  279. const fiber = await ctx.plugin(PythonCodeRuntime, { maxWallMs: 3000 })
  280. const runtime = ctx.codeRuntime as InstanceType<typeof PythonCodeRuntime>
  281. const result = await runtime.run(runtime.resolve({
  282. program: 'return 1',
  283. bindings: [{ global: 'tools', functions: { f: async () => 'x'.repeat(4 * 1024 * 1024) } }],
  284. }))
  285. expect(result.error?.kind).toBe('timeout')
  286. // The drain wait settled on `close` and cleaned up after itself. The
  287. // discriminating listener is `drain`: a `once('drain')` wait would leave
  288. // its wrapper attached to the destroyed pipe forever (the event never
  289. // fires again), while the fixed wait removes it. (`error` is not asserted:
  290. // the runtime's own `silenceStreamError` occupies one slot.)
  291. expect(proto).toBeDefined()
  292. expect(proto?.listenerCount('drain')).toBe(0)
  293. expect(proto?.listenerCount('close')).toBe(0)
  294. await fiber.dispose()
  295. })
  296. })