1
0

bootstrap.spec.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  1. import { describe, expect, it } from 'vitest'
  2. import { EventEmitter } from 'node:events'
  3. import { LogBuffer, makeBindingErrorClasses, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareCompletion, prepareException, runWorkerMain, 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. import { decodeWorkerJson, encodeWorkerJson } from '../src/worker-json.ts'
  7. /**
  8. * An in-process stand-in for the worker's parentPort: the test plays the
  9. * HOST side — inspect what the bootstrap posted, feed replies back — so
  10. * every line of worker-side logic runs under coverage without spawning an
  11. * isolate (real-worker behavior is pinned by runtime.spec.ts).
  12. */
  13. class FakePort implements BootstrapPort {
  14. sent: WorkerToHost[] = []
  15. private readonly emitter = new EventEmitter()
  16. /** Host-scripted responder; return undefined to leave the call pending. */
  17. respond: (message: WorkerToHost) => ReplyMessage | undefined = () => undefined
  18. postMessage(message: WorkerToHost): void {
  19. this.sent.push(message)
  20. const reply = this.respond(message)
  21. if (reply) queueMicrotask(() => this.emitter.emit('message', reply))
  22. }
  23. on(event: 'message', listener: (message: ReplyMessage) => void): void {
  24. this.emitter.on(event, listener)
  25. }
  26. deliver(message: ReplyMessage): void {
  27. this.emitter.emit('message', message)
  28. }
  29. logs(): string[] {
  30. return this.sent.filter(message => message.type === 'log').map(message => message.text)
  31. }
  32. done(): WorkerToHost | undefined {
  33. return this.sent.find(message => message.type === 'done')
  34. }
  35. doneValue(): unknown {
  36. const done = this.done()
  37. return done?.type === 'done' && done.value !== undefined ? decodeWorkerJson(done.value) : undefined
  38. }
  39. }
  40. function fakeStreams(): { stdout: PatchableStream; stderr: PatchableStream } {
  41. return { stdout: { write: () => true }, stderr: { write: () => true } }
  42. }
  43. /** Capture one promise rejection without Vitest's intentionally `any` matcher channel. */
  44. async function rejectionOf(promise: Promise<unknown>): Promise<unknown> {
  45. try {
  46. await promise
  47. return undefined
  48. } catch (error: unknown) {
  49. return error
  50. }
  51. }
  52. const BOOT = { maxOutputBytes: 65_536 }
  53. const TOOL_ERROR_CLASS = { name: 'ToolCallError', memberNameProperty: 'toolName' } as const
  54. /** One worker declaration for the Code Mode tools namespace. */
  55. function toolNamespace(names: string[]) {
  56. return { global: 'tools', names, errorClass: TOOL_ERROR_CLASS }
  57. }
  58. describe('LogBuffer', () => {
  59. it('streams entries to the sink until the byte budget, then emits one fitting prefix and reports the limit once', () => {
  60. const seen: string[] = []
  61. let limits = 0
  62. const buffer = new LogBuffer(15, text => seen.push(text), () => { limits += 1 })
  63. buffer.push('12345')
  64. buffer.push('123456')
  65. buffer.push('dropped')
  66. expect(seen).toEqual(['12345', '123'])
  67. expect(limits).toBe(1)
  68. expect(buffer.remainingOutputBytes()).toBe(0)
  69. const exactlyFull: string[] = []
  70. const fullBuffer = new LogBuffer(6, text => exactlyFull.push(text))
  71. fullBuffer.push('12')
  72. fullBuffer.push('no-prefix-fits')
  73. expect(exactlyFull).toEqual(['12'])
  74. })
  75. })
  76. describe('makeConsoleShim', () => {
  77. it('captures the five methods and renders non-strings inspect-style', () => {
  78. const seen: string[] = []
  79. const shim = makeConsoleShim(new LogBuffer(1_000, text => seen.push(text)))
  80. shim.log('plain', { a: 1 })
  81. shim.info('i')
  82. shim.warn('w')
  83. shim.error('e')
  84. shim.debug('d')
  85. expect(seen).toEqual(['plain { a: 1 }', 'i', 'w', 'e', 'd'])
  86. })
  87. })
  88. describe('captureStreamWrites', () => {
  89. it('redirects writes into the buffer and restores on request', () => {
  90. const seen: string[] = []
  91. const buffer = new LogBuffer(1_000, text => seen.push(text))
  92. let underlying = ''
  93. const stream: PatchableStream = { write: (chunk: unknown) => { underlying += String(chunk); return true } }
  94. const restore = captureStreamWrites(buffer, stream)
  95. stream.write('captured', 'utf8')
  96. stream.write(Buffer.from('bytes'))
  97. restore()
  98. stream.write('after')
  99. expect(seen).toEqual(['captured', 'bytes'])
  100. expect(underlying).toBe('after')
  101. })
  102. it('invokes the write callback asynchronously, in both optional-encoding shapes', async () => {
  103. const buffer = new LogBuffer(1_000, () => {})
  104. const stream: PatchableStream = { write: () => true }
  105. captureStreamWrites(buffer, stream)
  106. const calls: (Error | null | undefined)[] = []
  107. stream.write('two-arg', (error?: Error | null) => calls.push(error))
  108. stream.write('three-arg', 'utf8', (error?: Error | null) => calls.push(error))
  109. // Node's contract: the callback fires after the write call returns.
  110. expect(calls).toEqual([])
  111. await new Promise<void>(resolve => stream.write('awaited flush', resolve))
  112. expect(calls).toEqual([null, null])
  113. })
  114. it('still fires the callback for a write the exhausted budget drops', async () => {
  115. const buffer = new LogBuffer(4, () => {})
  116. const stream: PatchableStream = { write: () => true }
  117. captureStreamWrites(buffer, stream)
  118. stream.write('this write overflows the budget and is dropped')
  119. await new Promise<void>(resolve => stream.write('also dropped', resolve))
  120. })
  121. })
  122. describe('prepareCompletion', () => {
  123. it('omits undefined and passes lossless JSON values exactly', () => {
  124. expect(prepareCompletion(undefined, 100)).toEqual({})
  125. expect(prepareCompletion({ a: [1, 'two'] }, 100)).toEqual({ value: encodeWorkerJson({ a: [1, 'two'] }) })
  126. })
  127. it('turns every lossy completion shape into invalid-output', () => {
  128. const cyclic: Record<string, unknown> = {}
  129. cyclic.self = cyclic
  130. const sparse = Array(2)
  131. class Exotic { readonly marker = true }
  132. for (const value of [{ fn: () => 1 }, -0, Number.POSITIVE_INFINITY, sparse, cyclic, new Exotic()]) {
  133. expect(prepareCompletion(value, 1_000)).toEqual({
  134. error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' },
  135. })
  136. }
  137. })
  138. it('reports an oversized value instead of substituting rendered text', () => {
  139. expect(prepareCompletion('x'.repeat(50), 10)).toEqual({
  140. error: { kind: 'output-limit', message: 'outer output exceeded 10 bytes' },
  141. })
  142. })
  143. it('measures the exact JSON serialization at and over the boundary', () => {
  144. expect(prepareCompletion('€', 5)).toEqual({ value: encodeWorkerJson('€') })
  145. expect(prepareCompletion('€', 4)).toEqual({
  146. error: { kind: 'output-limit', message: 'outer output exceeded 4 bytes' },
  147. })
  148. })
  149. it('contains a getter failure as invalid-output', () => {
  150. const value = Object.defineProperty({}, 'x', { enumerable: true, get() { throw new Error('getter exploded') } })
  151. expect(prepareCompletion(value, 1_000)).toEqual({
  152. error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' },
  153. })
  154. })
  155. it('uses the remaining combined budget for invalid-output diagnostics', () => {
  156. expect(prepareCompletion(() => 1, 4, 64)).toEqual({
  157. error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
  158. })
  159. })
  160. })
  161. describe('prepareException', () => {
  162. it('passes a fitting diagnostic and rejects one byte over without carrying its text', () => {
  163. expect(prepareException('boom', 6, 64)).toEqual({ error: { kind: 'exception', message: 'boom' } })
  164. expect(prepareException('boom', 5, 64)).toEqual({
  165. error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
  166. })
  167. })
  168. it('contains a thrown value whose string conversion fails', () => {
  169. const thrown = { toString() { throw new Error('cannot render') } }
  170. expect(prepareException(thrown, 1_000)).toEqual({
  171. error: { kind: 'exception', message: 'program threw an unrenderable value' },
  172. })
  173. const strangeStack = Object.defineProperty(new Error('ignored'), 'stack', { value: 42 })
  174. expect(prepareException(strangeStack, 1_000)).toEqual({
  175. error: { kind: 'exception', message: '42' },
  176. })
  177. })
  178. })
  179. describe('makeNamespaces', () => {
  180. it('rejects a malformed success reply instead of resolving a lossy binding value', async () => {
  181. const port = new FakePort()
  182. const pending = new Map<number, PendingCall>()
  183. wireReplies(port, pending)
  184. const result = new Promise<unknown>((resolve, reject) => { pending.set(1, { resolve, reject }) })
  185. port.deliver({ type: 'reply', id: 1, ok: true, value: [undefined] as never })
  186. await expect(result).rejects.toThrow('binding resolution must be lossless JSON')
  187. })
  188. it('exposes prototype-colliding names as ordinary own properties', async () => {
  189. const port = new FakePort()
  190. port.respond = message => message.type === 'call'
  191. ? { type: 'reply', id: message.id, ok: true, value: encodeWorkerJson(`${message.name}-ok`) }
  192. : undefined
  193. const pending = new Map<number, PendingCall>()
  194. wireReplies(port, pending)
  195. const [tools] = makeNamespaces({ namespaces: [{ global: 'tools', names: ['__proto__', 'constructor', 'toString'] }] }, port, pending, { value: 1 }) as [Record<string, (args: unknown) => Promise<unknown>>]
  196. expect(Object.getPrototypeOf(tools)).toBeNull()
  197. await expect(tools['__proto__']?.({})).resolves.toBe('__proto__-ok')
  198. await expect(tools['constructor']?.({})).resolves.toBe('constructor-ok')
  199. await expect(tools['toString']?.({})).resolves.toBe('toString-ok')
  200. })
  201. it('rejects a postMessage clone failure without leaking the pending entry', async () => {
  202. let firstCall = true
  203. const throwingPort: BootstrapPort = {
  204. // First call throws an Error (the real DataCloneError shape), the
  205. // second a bare string — the rejection renders both.
  206. postMessage: () => {
  207. if (firstCall) { firstCall = false; throw new Error('DataCloneError-ish') }
  208. throw 'raw-clone-failure'
  209. },
  210. on: () => {},
  211. }
  212. const pending = new Map<number, PendingCall>()
  213. const data = { namespaces: [toolNamespace(['x'])] }
  214. const errorClasses = makeBindingErrorClasses(data)
  215. const ToolCallError = errorClasses.get('tools')
  216. const [tools] = makeNamespaces(
  217. data,
  218. throwingPort,
  219. pending,
  220. { value: 1 },
  221. errorClasses,
  222. ) as [Record<string, (args: unknown) => Promise<unknown>>]
  223. const first = await rejectionOf(tools.x?.({ first: true }) ?? Promise.resolve())
  224. const second = await rejectionOf(tools.x?.({ second: true }) ?? Promise.resolve())
  225. expect(first).toMatchObject({ name: 'ToolCallError', toolName: 'x' })
  226. expect(second).toMatchObject({ name: 'ToolCallError', toolName: 'x' })
  227. expect(first).toBeInstanceOf(ToolCallError)
  228. expect(second).toBeInstanceOf(ToolCallError)
  229. expect((first as Error).message).toMatch(/DataCloneError-ish/)
  230. expect((second as Error).message).toMatch(/raw-clone-failure/)
  231. expect(pending.size).toBe(0)
  232. })
  233. it('rejects lossy arguments before posting or allocating a call id', async () => {
  234. let posts = 0
  235. const port: BootstrapPort = { postMessage: () => { posts += 1 }, on: () => {} }
  236. const pending = new Map<number, PendingCall>()
  237. const nextId = { value: 1 }
  238. const [tools] = makeNamespaces(
  239. { namespaces: [toolNamespace(['x'])] }, port, pending, nextId,
  240. ) as [Record<string, (args: unknown) => Promise<unknown>>]
  241. const decorated = [1]
  242. Object.defineProperty(decorated, 'extra', { value: true })
  243. const throwing = Object.defineProperty({}, 'value', {
  244. enumerable: true,
  245. get: () => { throw new Error('getter exploded') },
  246. })
  247. for (const value of [() => 1, new Date(), decorated, throwing]) {
  248. const failure = await rejectionOf(tools.x?.(value) ?? Promise.resolve())
  249. expect(failure).toMatchObject({
  250. name: 'ToolCallError', toolName: 'x', message: 'binding arguments must be lossless JSON',
  251. })
  252. }
  253. expect(posts).toBe(0)
  254. expect(pending.size).toBe(0)
  255. expect(nextId.value).toBe(1)
  256. })
  257. it('rejects an oversized transport frame before posting or allocating a call id', async () => {
  258. const port = new FakePort()
  259. const pending = new Map<number, PendingCall>()
  260. const nextId = { value: 1 }
  261. const data = { namespaces: [toolNamespace(['x'])] }
  262. const [tools] = makeNamespaces(
  263. data, port, pending, nextId, makeBindingErrorClasses(data), 64,
  264. ) as [Record<string, (args: unknown) => Promise<unknown>>]
  265. await expect(tools.x?.({ text: 'x'.repeat(64) })).rejects.toMatchObject({
  266. name: 'ToolCallError', toolName: 'x', message: 'binding arguments exceed maxFrameBytes',
  267. })
  268. expect(port.sent).toEqual([])
  269. expect(pending.size).toBe(0)
  270. expect(nextId.value).toBe(1)
  271. })
  272. it('uses ordinary Error for non-tools namespace failures', async () => {
  273. const deniedPort = new FakePort()
  274. deniedPort.respond = message => message.type === 'call'
  275. ? { type: 'reply', id: message.id, ok: false, message: 'helper denied' }
  276. : undefined
  277. const deniedPending = new Map<number, PendingCall>()
  278. wireReplies(deniedPort, deniedPending)
  279. const [helpers] = makeNamespaces({ namespaces: [{ global: 'helpers', names: ['x'] }] }, deniedPort, deniedPending, { value: 1 }) as [Record<string, (args: unknown) => Promise<unknown>>]
  280. const denied = await rejectionOf(helpers.x?.({}) ?? Promise.resolve())
  281. expect(denied).toBeInstanceOf(Error)
  282. expect(denied).toMatchObject({ name: 'Error', message: 'helper denied' })
  283. expect(denied).not.toHaveProperty('toolName')
  284. const invalid = await rejectionOf(helpers.x?.(() => 1) ?? Promise.resolve())
  285. expect(invalid).toBeInstanceOf(Error)
  286. expect((invalid as Error).message).toBe('binding arguments must be lossless JSON')
  287. const clonePort: BootstrapPort = { postMessage: () => { throw new Error('clone failed') }, on: () => {} }
  288. const [cloneHelpers] = makeNamespaces({ namespaces: [{ global: 'helpers', names: ['x'] }] }, clonePort, new Map(), { value: 1 }) as [Record<string, (args: unknown) => Promise<unknown>>]
  289. const cloneFailure = await rejectionOf(cloneHelpers.x?.({}) ?? Promise.resolve())
  290. expect(cloneFailure).toBeInstanceOf(Error)
  291. expect(cloneFailure).not.toHaveProperty('toolName')
  292. })
  293. })
  294. describe('runWorkerMain', () => {
  295. it('runs a program end-to-end: bindings, console, return value', async () => {
  296. const port = new FakePort()
  297. port.respond = (message) => {
  298. if (message.type !== 'call') return undefined
  299. const args = decodeWorkerJson(message.args) as { n: number }
  300. return { type: 'reply', id: message.id, ok: true, value: encodeWorkerJson(args.n * 2) }
  301. }
  302. await runWorkerMain(port, {
  303. ...BOOT,
  304. code: 'const doubled = await tools.double({ n: 21 }); console.log("got", doubled); return { doubled };',
  305. namespaces: [{ global: 'tools', names: ['double'] }],
  306. }, fakeStreams())
  307. expect(port.logs()).toEqual(['got 42'])
  308. expect(port.doneValue()).toEqual({ doubled: 42 })
  309. })
  310. it('reports worker-side log capture overflow before completing', async () => {
  311. const port = new FakePort()
  312. await runWorkerMain(port, {
  313. maxOutputBytes: 4,
  314. code: 'console.log("12345"); return null',
  315. namespaces: [],
  316. }, fakeStreams())
  317. expect(port.logs()).toEqual([])
  318. expect(port.sent).toContainEqual({ type: 'output-limit' })
  319. expect(port.done()).toEqual({
  320. type: 'done',
  321. error: { kind: 'output-limit', message: 'outer output exceeded 4 bytes' },
  322. })
  323. })
  324. it('reports output-limit before posting a completion that expands past the transport cap', async () => {
  325. const port = new FakePort()
  326. await runWorkerMain(port, {
  327. maxOutputBytes: 1_000,
  328. code: 'return Array.from({ length: 100 }, () => [])',
  329. namespaces: [],
  330. }, fakeStreams(), 100)
  331. expect(port.sent.at(-1)).toEqual({ type: 'output-limit' })
  332. })
  333. it('reports a thrown program error on the done message', async () => {
  334. const port = new FakePort()
  335. await runWorkerMain(port, { ...BOOT, code: 'throw new Error("boom")', namespaces: [] }, fakeStreams())
  336. const done = port.done()
  337. expect(done?.type).toBe('done')
  338. expect(done?.type === 'done' ? done.error?.kind : undefined).toBe('exception')
  339. expect(done?.type === 'done' ? done.error?.message : undefined).toContain('boom')
  340. expect(done?.type === 'done' ? done.value : undefined).toBeUndefined()
  341. })
  342. it('renders non-Error throws and stack-less Errors on the done message', async () => {
  343. const rawPort = new FakePort()
  344. await runWorkerMain(rawPort, { ...BOOT, code: 'throw "raw-throw"', namespaces: [] }, fakeStreams())
  345. expect(rawPort.done()).toEqual({ type: 'done', error: { kind: 'exception', message: 'raw-throw' } })
  346. const barePort = new FakePort()
  347. await runWorkerMain(barePort, { ...BOOT, code: 'const e = new Error("bare"); e.stack = undefined; throw e', namespaces: [] }, fakeStreams())
  348. expect(barePort.done()).toEqual({ type: 'done', error: { kind: 'exception', message: 'bare' } })
  349. })
  350. it('replaces giant thrown strings and Error stacks before posting the done message', async () => {
  351. const rawPort = new FakePort()
  352. await runWorkerMain(rawPort, {
  353. maxOutputBytes: 64,
  354. code: 'throw "x".repeat(1_000_000)',
  355. namespaces: [],
  356. }, fakeStreams())
  357. expect(rawPort.done()).toEqual({
  358. type: 'done',
  359. error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
  360. })
  361. const stackPort = new FakePort()
  362. await runWorkerMain(stackPort, {
  363. maxOutputBytes: 64,
  364. code: 'throw new Error("x".repeat(1_000_000))',
  365. namespaces: [],
  366. }, fakeStreams())
  367. expect(stackPort.done()).toEqual({
  368. type: 'done',
  369. error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
  370. })
  371. })
  372. it('surfaces a host failure reply as a program-side rejection it can catch', async () => {
  373. const port = new FakePort()
  374. port.respond = message => message.type === 'call' ? { type: 'reply', id: message.id, ok: false, message: 'denied by host' } : undefined
  375. await runWorkerMain(port, {
  376. ...BOOT,
  377. code: 'try { await tools.x({}) } catch (error) { return { caught: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message } }',
  378. namespaces: [toolNamespace(['x'])],
  379. }, fakeStreams())
  380. expect(port.doneValue()).toEqual({ caught: true, name: 'ToolCallError', toolName: 'x', message: 'denied by host' })
  381. })
  382. it('materializes a consumer-declared rejection class without knowing the namespace', async () => {
  383. const port = new FakePort()
  384. port.respond = message => message.type === 'call'
  385. ? { type: 'reply', id: message.id, ok: false, message: 'helper denied' }
  386. : undefined
  387. await runWorkerMain(port, {
  388. ...BOOT,
  389. code: 'try { await helpers.x({}) } catch (error) { return { caught: error instanceof HelperCallError, name: error.name, helperName: error.helperName, message: error.message } }',
  390. namespaces: [{
  391. global: 'helpers',
  392. names: ['x'],
  393. errorClass: { name: 'HelperCallError', memberNameProperty: 'helperName' },
  394. }],
  395. }, fakeStreams())
  396. expect(port.doneValue()).toEqual({ caught: true, name: 'HelperCallError', helperName: 'x', message: 'helper denied' })
  397. })
  398. it('ignores replies for unknown pending ids', async () => {
  399. const port = new FakePort()
  400. port.respond = (message) => {
  401. if (message.type !== 'call') return undefined
  402. // Deliver a stray reply first; the real one follows.
  403. port.deliver({ type: 'reply', id: 9_999, ok: true, value: encodeWorkerJson('stray') })
  404. return { type: 'reply', id: message.id, ok: true, value: encodeWorkerJson('real') }
  405. }
  406. await runWorkerMain(port, {
  407. ...BOOT,
  408. code: 'return await tools.x({})',
  409. namespaces: [{ global: 'tools', names: ['x'] }],
  410. }, fakeStreams())
  411. expect(port.doneValue()).toBe('real')
  412. })
  413. it('captures raw stream writes through the patched process streams', async () => {
  414. const port = new FakePort()
  415. const streams = fakeStreams()
  416. await runWorkerMain(port, { ...BOOT, code: 'return 1', namespaces: [] }, streams)
  417. streams.stdout.write('never seen — already restored? no: patch persists in worker')
  418. // The patch stays installed for the worker's lifetime; writes during the
  419. // program landed in order. Here the program wrote nothing via streams, so
  420. // only the post-run write above went through the patched slot.
  421. expect(port.logs().at(-1)).toBe('never seen — already restored? no: patch persists in worker')
  422. })
  423. })