bootstrap.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. import { describe, expect, it } from 'vitest'
  2. import { EventEmitter } from 'node:events'
  3. import { LogBuffer, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareValue, runWorkerMain, truncateUtf8Bytes, wireReplies } from '../src/bootstrap.ts'
  4. import type { BootstrapPort, PatchableStream, PendingCall } from '../src/bootstrap.ts'
  5. import type { ReplyMessage, WorkerToHost } from '../src/protocol.ts'
  6. /**
  7. * An in-process stand-in for the worker's parentPort: the test plays the
  8. * HOST side — inspect what the bootstrap posted, feed replies back — so
  9. * every line of worker-side logic runs under coverage without spawning an
  10. * isolate (real-worker behavior is pinned by runtime.spec.ts).
  11. */
  12. class FakePort implements BootstrapPort {
  13. sent: WorkerToHost[] = []
  14. private readonly emitter = new EventEmitter()
  15. /** Host-scripted responder; return undefined to leave the call pending. */
  16. respond: (message: WorkerToHost) => ReplyMessage | undefined = () => undefined
  17. postMessage(message: WorkerToHost): void {
  18. this.sent.push(message)
  19. const reply = this.respond(message)
  20. if (reply) queueMicrotask(() => this.emitter.emit('message', reply))
  21. }
  22. on(event: 'message', listener: (message: ReplyMessage) => void): void {
  23. this.emitter.on(event, listener)
  24. }
  25. deliver(message: ReplyMessage): void {
  26. this.emitter.emit('message', message)
  27. }
  28. logs(): string[] {
  29. return this.sent.filter(message => message.type === 'log').map(message => message.text)
  30. }
  31. done(): WorkerToHost | undefined {
  32. return this.sent.find(message => message.type === 'done')
  33. }
  34. }
  35. function fakeStreams(): { stdout: PatchableStream; stderr: PatchableStream } {
  36. return { stdout: { write: () => true }, stderr: { write: () => true } }
  37. }
  38. const BOOT = { maxLogBytes: 65_536, maxValueBytes: 32_768 }
  39. describe('LogBuffer', () => {
  40. it('streams entries to the sink until the byte budget, then emits one marker and drops the rest', () => {
  41. const seen: string[] = []
  42. const buffer = new LogBuffer(10, text => seen.push(text))
  43. buffer.push('12345')
  44. buffer.push('123456')
  45. buffer.push('dropped')
  46. expect(seen).toEqual([
  47. '12345',
  48. '[dsh-code-runtime-worker] log capture truncated at 10 bytes',
  49. ])
  50. })
  51. })
  52. describe('makeConsoleShim', () => {
  53. it('captures the five methods and renders non-strings inspect-style', () => {
  54. const seen: string[] = []
  55. const shim = makeConsoleShim(new LogBuffer(1_000, text => seen.push(text)))
  56. shim.log('plain', { a: 1 })
  57. shim.info('i')
  58. shim.warn('w')
  59. shim.error('e')
  60. shim.debug('d')
  61. expect(seen).toEqual(['plain { a: 1 }', 'i', 'w', 'e', 'd'])
  62. })
  63. })
  64. describe('captureStreamWrites', () => {
  65. it('redirects writes into the buffer and restores on request', () => {
  66. const seen: string[] = []
  67. const buffer = new LogBuffer(1_000, text => seen.push(text))
  68. let underlying = ''
  69. const stream: PatchableStream = { write: (chunk: unknown) => { underlying += String(chunk); return true } }
  70. const restore = captureStreamWrites(buffer, stream)
  71. stream.write('captured', 'utf8')
  72. stream.write(Buffer.from('bytes'))
  73. restore()
  74. stream.write('after')
  75. expect(seen).toEqual(['captured', 'bytes'])
  76. expect(underlying).toBe('after')
  77. })
  78. it('invokes the write callback asynchronously, in both optional-encoding shapes', async () => {
  79. const buffer = new LogBuffer(1_000, () => {})
  80. const stream: PatchableStream = { write: () => true }
  81. captureStreamWrites(buffer, stream)
  82. const calls: (Error | null | undefined)[] = []
  83. stream.write('two-arg', (error?: Error | null) => calls.push(error))
  84. stream.write('three-arg', 'utf8', (error?: Error | null) => calls.push(error))
  85. // Node's contract: the callback fires after the write call returns.
  86. expect(calls).toEqual([])
  87. await new Promise<void>(resolve => stream.write('awaited flush', resolve))
  88. expect(calls).toEqual([null, null])
  89. })
  90. it('still fires the callback for a write the exhausted budget drops', async () => {
  91. const buffer = new LogBuffer(4, () => {})
  92. const stream: PatchableStream = { write: () => true }
  93. captureStreamWrites(buffer, stream)
  94. stream.write('this write overflows the budget and is dropped')
  95. await new Promise<void>(resolve => stream.write('also dropped', resolve))
  96. })
  97. })
  98. describe('prepareValue', () => {
  99. it('omits undefined, passes small cloneable values raw', () => {
  100. expect(prepareValue(undefined, 100)).toEqual({})
  101. expect(prepareValue({ a: [1, 'two'] }, 100)).toEqual({ value: { a: [1, 'two'] } })
  102. })
  103. it('replaces a non-cloneable value with its rendering', () => {
  104. const { value } = prepareValue({ fn: () => 1 }, 1_000)
  105. expect(typeof value).toBe('string')
  106. expect(value).toContain('fn')
  107. })
  108. it('replaces an oversized value with a truncation-marked capped rendering', () => {
  109. const { value } = prepareValue('x'.repeat(50), 10)
  110. expect(value).toBe(`${'x'.repeat(10)}… [truncated]`)
  111. })
  112. it('measures a container by its structured-clone wire size, not its bounded rendering', () => {
  113. // The bounded inspect rendering of a huge array is tiny ("... N more
  114. // items"), but its real cross-boundary size is not — the cap must catch
  115. // it, replacing the value with that bounded rendering.
  116. const huge = new Array(50_000).fill(7)
  117. const { value } = prepareValue(huge, 1_000)
  118. expect(typeof value).toBe('string')
  119. expect(value).toContain('more items')
  120. })
  121. it('caps a multibyte string by UTF-8 bytes, not UTF-16 length', () => {
  122. // 4 code units but 12 UTF-8 bytes: a length-counting cap would pass the
  123. // full string through untruncated.
  124. expect(prepareValue('€€€€', 4)).toEqual({ value: '€… [truncated]' })
  125. })
  126. it('caps a multibyte rendering by UTF-8 bytes too', () => {
  127. // Wire size (24-byte string inside an array) exceeds the cap, so the
  128. // value crosses as its rendering — whose truncation must also be
  129. // byte-exact: "[ '" (3 bytes) + two € (6 bytes) = 9; a third € would
  130. // overflow the 10-byte budget.
  131. expect(prepareValue(['€€€€€€€€'], 10)).toEqual({ value: "[ '€€… [truncated]" })
  132. })
  133. })
  134. describe('truncateUtf8Bytes', () => {
  135. it('returns a fitting string whole', () => {
  136. expect(truncateUtf8Bytes('fits', 4)).toBe('fits')
  137. })
  138. it('cuts at a code-point boundary, never mid-surrogate-pair', () => {
  139. // Each 😀 is one code point, two code units, four UTF-8 bytes: a 5-byte
  140. // budget fits exactly one — and never leaves a lone surrogate behind.
  141. const cut = truncateUtf8Bytes('😀😀', 5)
  142. expect(cut).toBe('😀')
  143. expect(Buffer.byteLength(truncateUtf8Bytes('😀😀', 3), 'utf8')).toBe(0)
  144. })
  145. })
  146. describe('makeNamespaces', () => {
  147. it('exposes prototype-colliding names as ordinary own properties', async () => {
  148. const port = new FakePort()
  149. port.respond = message => message.type === 'call' ? { type: 'reply', id: message.id, ok: true, value: `${message.name}-ok` } : undefined
  150. const pending = new Map<number, PendingCall>()
  151. wireReplies(port, pending)
  152. const [tools] = makeNamespaces({ namespaces: [{ global: 'tools', names: ['__proto__', 'constructor', 'toString'] }] }, port, pending, { value: 1 }) as [Record<string, (args: unknown) => Promise<unknown>>]
  153. expect(Object.getPrototypeOf(tools)).toBeNull()
  154. await expect(tools['__proto__']?.({})).resolves.toBe('__proto__-ok')
  155. await expect(tools['constructor']?.({})).resolves.toBe('constructor-ok')
  156. await expect(tools['toString']?.({})).resolves.toBe('toString-ok')
  157. })
  158. it('rejects a non-cloneable argument without leaking the pending entry', async () => {
  159. let firstCall = true
  160. const throwingPort: BootstrapPort = {
  161. // First call throws an Error (the real DataCloneError shape), the
  162. // second a bare string — the rejection renders both.
  163. postMessage: () => {
  164. if (firstCall) { firstCall = false; throw new Error('DataCloneError-ish') }
  165. throw 'raw-clone-failure'
  166. },
  167. on: () => {},
  168. }
  169. const pending = new Map<number, PendingCall>()
  170. const [tools] = makeNamespaces({ namespaces: [{ global: 'tools', names: ['x'] }] }, throwingPort, pending, { value: 1 }) as [Record<string, (args: unknown) => Promise<unknown>>]
  171. await expect(tools.x?.(() => 1)).rejects.toThrow(/structured-cloneable: DataCloneError-ish/)
  172. await expect(tools.x?.(() => 1)).rejects.toThrow(/structured-cloneable: raw-clone-failure/)
  173. expect(pending.size).toBe(0)
  174. })
  175. })
  176. describe('runWorkerMain', () => {
  177. it('runs a program end-to-end: bindings, console, return value', async () => {
  178. const port = new FakePort()
  179. port.respond = message => message.type === 'call' ? { type: 'reply', id: message.id, ok: true, value: (message.args as { n: number }).n * 2 } : undefined
  180. await runWorkerMain(port, {
  181. ...BOOT,
  182. code: 'const doubled = await tools.double({ n: 21 }); console.log("got", doubled); return { doubled };',
  183. namespaces: [{ global: 'tools', names: ['double'] }],
  184. }, fakeStreams())
  185. expect(port.logs()).toEqual(['got 42'])
  186. expect(port.done()).toEqual({ type: 'done', value: { doubled: 42 } })
  187. })
  188. it('reports a thrown program error on the done message', async () => {
  189. const port = new FakePort()
  190. await runWorkerMain(port, { ...BOOT, code: 'throw new Error("boom")', namespaces: [] }, fakeStreams())
  191. const done = port.done()
  192. expect(done?.type).toBe('done')
  193. expect(done?.type === 'done' ? done.error?.message : undefined).toContain('boom')
  194. expect(done?.type === 'done' ? done.value : undefined).toBeUndefined()
  195. })
  196. it('renders non-Error throws and stack-less Errors on the done message', async () => {
  197. const rawPort = new FakePort()
  198. await runWorkerMain(rawPort, { ...BOOT, code: 'throw "raw-throw"', namespaces: [] }, fakeStreams())
  199. expect(rawPort.done()).toEqual({ type: 'done', error: { message: 'raw-throw' } })
  200. const barePort = new FakePort()
  201. await runWorkerMain(barePort, { ...BOOT, code: 'const e = new Error("bare"); e.stack = undefined; throw e', namespaces: [] }, fakeStreams())
  202. expect(barePort.done()).toEqual({ type: 'done', error: { message: 'bare' } })
  203. })
  204. it('surfaces a host failure reply as a program-side rejection it can catch', async () => {
  205. const port = new FakePort()
  206. port.respond = message => message.type === 'call' ? { type: 'reply', id: message.id, ok: false, message: 'denied by host' } : undefined
  207. await runWorkerMain(port, {
  208. ...BOOT,
  209. code: 'try { await tools.x({}) } catch (error) { return `caught: ${error.message}` }',
  210. namespaces: [{ global: 'tools', names: ['x'] }],
  211. }, fakeStreams())
  212. expect(port.done()).toEqual({ type: 'done', value: 'caught: denied by host' })
  213. })
  214. it('ignores replies for unknown pending ids', async () => {
  215. const port = new FakePort()
  216. port.respond = (message) => {
  217. if (message.type !== 'call') return undefined
  218. // Deliver a stray reply first; the real one follows.
  219. port.deliver({ type: 'reply', id: 9_999, ok: true, value: 'stray' })
  220. return { type: 'reply', id: message.id, ok: true, value: 'real' }
  221. }
  222. await runWorkerMain(port, {
  223. ...BOOT,
  224. code: 'return await tools.x({})',
  225. namespaces: [{ global: 'tools', names: ['x'] }],
  226. }, fakeStreams())
  227. expect(port.done()).toEqual({ type: 'done', value: 'real' })
  228. })
  229. it('captures raw stream writes through the patched process streams', async () => {
  230. const port = new FakePort()
  231. const streams = fakeStreams()
  232. await runWorkerMain(port, { ...BOOT, code: 'return 1', namespaces: [] }, streams)
  233. streams.stdout.write('never seen — already restored? no: patch persists in worker')
  234. // The patch stays installed for the worker's lifetime; writes during the
  235. // program landed in order. Here the program wrote nothing via streams, so
  236. // only the post-run write above went through the patched slot.
  237. expect(port.logs().at(-1)).toBe('never seen — already restored? no: patch persists in worker')
  238. })
  239. })