1
0

boot-write-failure.spec.ts 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
  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 '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 { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() }))
  16. vi.mock('node:child_process', async importOriginal => ({
  17. ...(await importOriginal<typeof import('node:child_process')>()),
  18. spawn: spawnMock,
  19. }))
  20. const { PythonCodeRuntime } = await import('../src/index.ts')
  21. /** A `child_process.ChildProcess` stand-in whose fd-3 pipe rejects every write. */
  22. function fakeChildWithThrowingFd3(): EventEmitter {
  23. const child = new EventEmitter() as EventEmitter & {
  24. pid?: number
  25. stdout: PassThrough
  26. stderr: PassThrough
  27. stdio: unknown[]
  28. }
  29. // Leave `pid` absent: `finish()` still runs its `clearTimeout(wallTimer)` /
  30. // `removeEventListener(onAbort)` prologue (the TDZ site) before short-
  31. // circuiting on `child.pid === undefined` to `settle` instead of waiting on a
  32. // `close` this fake never emits, so the run resolves promptly.
  33. child.stdout = new PassThrough()
  34. child.stderr = new PassThrough()
  35. // A duplex whose `write` throws synchronously, standing in for an fd-3 pipe
  36. // that fails the moment the boot frame is issued.
  37. const proto = new PassThrough()
  38. proto.write = () => { throw Object.assign(new Error('EPIPE: broken pipe, write'), { code: 'EPIPE' }) }
  39. child.stdio = [new PassThrough(), child.stdout, child.stderr, proto]
  40. return child
  41. }
  42. afterEach(() => {
  43. spawnMock.mockReset()
  44. })
  45. /** A child whose fd-3 pipe accepts the boot write, then rejects the run write. */
  46. function fakeChildWithAckThenThrowingFd3(): EventEmitter {
  47. const child = new EventEmitter() as EventEmitter & {
  48. pid?: number
  49. stdout: PassThrough
  50. stderr: PassThrough
  51. stdio: unknown[]
  52. }
  53. child.stdout = new PassThrough()
  54. child.stderr = new PassThrough()
  55. const proto = new PassThrough()
  56. let writes = 0
  57. proto.write = () => {
  58. writes += 1
  59. if (writes === 1) return true // The boot frame goes out.
  60. throw Object.assign(new Error('EPIPE: broken pipe, write'), { code: 'EPIPE' })
  61. }
  62. child.stdio = [new PassThrough(), child.stdout, child.stderr, proto]
  63. // Emit the boot-ack after the boot write, so the run-frame write fires and
  64. // hits the throwing pipe.
  65. setImmediate(() => proto.emit('data', Buffer.from('{"type":"boot-ack"}\n')))
  66. return child
  67. }
  68. describe('PythonCodeRuntime — boot-write failure', () => {
  69. it('resolves a worker-exit when the fd-3 boot write throws (no TDZ ReferenceError)', async () => {
  70. // Before the fix, the boot-write block ran BEFORE `wallTimer`, `onAbort`,
  71. // and `live` were initialized, so its `finish()` (which clears `wallTimer`,
  72. // removes `onAbort`, and — through `settle` — deletes `live`) hit the
  73. // temporal dead zone and threw a ReferenceError. That escaped the Promise
  74. // executor and REJECTED run() instead of resolving the worker-exit the catch
  75. // constructs. This test would see that rejection; the fix makes it resolve.
  76. spawnMock.mockImplementation(() => fakeChildWithThrowingFd3())
  77. const ctx = new Context()
  78. const fiber = await ctx.plugin(PythonCodeRuntime)
  79. const runtime = ctx.codeRuntime as InstanceType<typeof PythonCodeRuntime>
  80. const result = await runtime.run({ program: 'return 1', bindings: [] })
  81. expect(result.error?.kind).toBe('worker-exit')
  82. expect(result.error?.message).toContain('failed to boot python subprocess')
  83. await fiber.dispose()
  84. })
  85. it('resolves a worker-exit and removes the staging dir when spawn throws synchronously', async () => {
  86. // `spawn` can throw same-tick — EMFILE on a descriptor-exhausted host, or a
  87. // libuv-level failure — before the Promise executor and its settlement path
  88. // exist. Left uncaught it rejected run() (the seam permits rejection only for
  89. // misuse) and stranded the staging directory materializePyScripts had just
  90. // written, which only settle() removes. The fix catches it, unlinks the
  91. // directory, and resolves the same `worker-exit` class as an async ENOENT.
  92. //
  93. // Capture THIS run's exact staging dir from the argv the mocked spawn
  94. // received (`['-I', <dir>/bootstrap.py]`) and assert only that path is gone.
  95. // A tmpdir scan — even a set difference against a pre-run snapshot — would
  96. // flake under vitest's forks pool: a sibling worker creating its own
  97. // `dsh-code-runtime-python-*` dir in the window reads as a leak here. Keying
  98. // off our own argv is fully isolated from concurrent staging.
  99. let stagedBootstrap: string | undefined
  100. spawnMock.mockImplementation((_bin: string, args: string[]) => {
  101. stagedBootstrap = args[args.length - 1]
  102. throw Object.assign(new Error('EMFILE: too many open files'), { code: 'EMFILE' })
  103. })
  104. const ctx = new Context()
  105. const fiber = await ctx.plugin(PythonCodeRuntime)
  106. const runtime = ctx.codeRuntime as InstanceType<typeof PythonCodeRuntime>
  107. const result = await runtime.run({ program: 'return 1', bindings: [] })
  108. expect(result.error?.kind).toBe('worker-exit')
  109. expect(result.error?.message).toContain('python spawn error')
  110. expect(stagedBootstrap).toBeDefined()
  111. expect(existsSync(dirname(stagedBootstrap as string))).toBe(false)
  112. await fiber.dispose()
  113. })
  114. it('resolves a worker-exit when the run write after boot-ack throws', async () => {
  115. // The run frame goes out from the boot-ack handler; a pipe that accepts
  116. // the boot frame but rejects the run write must settle the run as a
  117. // worker-exit rather than reject run() or leave it hanging.
  118. spawnMock.mockImplementation(() => fakeChildWithAckThenThrowingFd3())
  119. const ctx = new Context()
  120. const fiber = await ctx.plugin(PythonCodeRuntime)
  121. const runtime = ctx.codeRuntime as InstanceType<typeof PythonCodeRuntime>
  122. const result = await runtime.run({ program: 'return 1', bindings: [] })
  123. expect(result.error?.kind).toBe('worker-exit')
  124. expect(result.error?.message).toContain('failed to boot python subprocess')
  125. await fiber.dispose()
  126. })
  127. })