boot-write-failure.spec.ts 12 KB

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