runtime.spec.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import { WorkerCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker'
  4. import type { Config } from '@deepseek-ai/dsh-code-runtime-worker'
  5. import type { CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
  6. /**
  7. * Integration suite over REAL worker threads (no mocks — workers are cheap
  8. * and local, per docs/testing.md's real-over-mock policy). Each test builds
  9. * a fresh context so budgets can be tuned per case.
  10. */
  11. async function setup(config: Config = {}) {
  12. const ctx = new Context()
  13. await ctx.plugin(WorkerCodeRuntime, config)
  14. const runtime = ctx.codeRuntime as WorkerCodeRuntime
  15. return { ctx, runtime }
  16. }
  17. /** Convenience: one namespace `tools` with the given functions. */
  18. function tools(functions: Record<string, (args: unknown) => Promise<unknown>>) {
  19. return [{ global: 'tools', functions }]
  20. }
  21. describe('WorkerCodeRuntime — programs and bindings (real workers)', () => {
  22. it('registers with the seam descriptors', async () => {
  23. const { runtime } = await setup()
  24. expect(runtime.language).toBe('typescript')
  25. expect(runtime.isolation).toBe('worker-thread')
  26. })
  27. it('runs TypeScript (erasable syntax), captures output in order, returns the value', async () => {
  28. const { runtime } = await setup()
  29. const result = await runtime.run({
  30. program: `
  31. interface Point { x: number; y: number }
  32. const p: Point = { x: 1, y: 2 } as Point;
  33. console.log('point', p);
  34. process.stdout.write('raw-out\\n');
  35. console.warn('careful');
  36. return p.x + p.y;
  37. `,
  38. bindings: [],
  39. })
  40. expect(result.error).toBeUndefined()
  41. expect(result.value).toBe(3)
  42. expect(result.logs).toEqual(['point { x: 1, y: 2 }', 'raw-out\n', 'careful'])
  43. })
  44. it('bridges binding calls both ways and rejects the program-side call on a host rejection', async () => {
  45. const { runtime } = await setup()
  46. const calls: unknown[] = []
  47. const result = await runtime.run({
  48. program: `
  49. const first = await tools.echo({ n: 1 });
  50. let caught = '';
  51. try { await tools.fail({}) } catch (error) { caught = error.message }
  52. let caughtRaw = '';
  53. try { await tools.failRaw({}) } catch (error) { caughtRaw = error.message }
  54. return { first, caught, caughtRaw };
  55. `,
  56. bindings: tools({
  57. echo: async (args) => { calls.push(args); return { echoed: args } },
  58. fail: async () => { throw new Error('nope') },
  59. // A non-Error throw: the host renders it, the program still catches.
  60. failRaw: async () => { throw 'raw-nope' },
  61. }),
  62. })
  63. expect(result.error).toBeUndefined()
  64. expect(result.value).toEqual({ first: { echoed: { n: 1 } }, caught: 'nope', caughtRaw: 'raw-nope' })
  65. expect(calls).toEqual([{ n: 1 }])
  66. })
  67. it('reports non-erasable syntax as an exception without spawning a worker', async () => {
  68. const { runtime } = await setup()
  69. const result = await runtime.run({ program: 'enum E { A }\nreturn 1', bindings: [] })
  70. expect(result.error?.kind).toBe('exception')
  71. expect(result.error?.message).toMatch(/enum|strip/i)
  72. })
  73. it('reports a runtime throw as an exception with the message', async () => {
  74. const { runtime } = await setup()
  75. const result = await runtime.run({ program: 'throw new Error("kaboom")', bindings: [] })
  76. expect(result.error?.kind).toBe('exception')
  77. expect(result.error?.message).toContain('kaboom')
  78. })
  79. it('gives the program an EMPTY environment', async () => {
  80. const { runtime } = await setup()
  81. const result = await runtime.run({ program: 'return JSON.stringify(process.env)', bindings: [] })
  82. expect(result.value).toBe('{}')
  83. })
  84. it('replaces a non-cloneable return value with a string rendering', async () => {
  85. const { runtime } = await setup()
  86. const result = await runtime.run({ program: 'return { f: () => 1 }', bindings: [] })
  87. expect(typeof result.value).toBe('string')
  88. })
  89. it('completes a program that returns nothing with no value at all', async () => {
  90. const { runtime } = await setup()
  91. const result = await runtime.run({ program: 'const x = 1', bindings: [] })
  92. expect(result.error).toBeUndefined()
  93. expect('value' in result).toBe(false)
  94. })
  95. it('keeps logs streamed before a failure', async () => {
  96. const { runtime } = await setup()
  97. const result = await runtime.run({
  98. program: 'console.log("before"); throw new Error("after-log")',
  99. bindings: [],
  100. })
  101. expect(result.error?.kind).toBe('exception')
  102. expect(result.logs).toContain('before')
  103. })
  104. })
  105. describe('WorkerCodeRuntime — budgets and containment (real workers)', () => {
  106. it('ends a hot loop at the compute budget — including behind a pending decoy dispatch', async () => {
  107. const { runtime } = await setup({ computeMs: 300, maxWallMs: 30_000 })
  108. const result = await runtime.run({
  109. // The decoy: fire a call at a never-resolving binding WITHOUT awaiting,
  110. // then spin. Host-side pending-call bookkeeping would pause a naive
  111. // budget here; measured busy time cannot be fooled.
  112. program: 'void tools.slow({}); for (;;) {}',
  113. bindings: tools({ slow: () => new Promise(() => {}) }),
  114. })
  115. expect(result.error?.kind).toBe('timeout')
  116. expect(result.error?.message).toContain('compute budget')
  117. }, 15_000)
  118. it('does not charge time spent awaiting a slow binding against the compute budget', async () => {
  119. // Keep the binding delay above the compute allowance while leaving enough
  120. // headroom for worker bootstrap on loaded CI hosts.
  121. const { runtime } = await setup({ computeMs: 1_000, maxWallMs: 30_000 })
  122. const result = await runtime.run({
  123. program: 'return await tools.slow({})',
  124. bindings: tools({ slow: () => new Promise(resolve => setTimeout(() => { resolve('slow-done') }, 1_500)) }),
  125. })
  126. expect(result.error).toBeUndefined()
  127. expect(result.value).toBe('slow-done')
  128. }, 15_000)
  129. it('ends an idle-forever run at the wall-clock ceiling', async () => {
  130. const { runtime } = await setup({ computeMs: 30_000, maxWallMs: 400 })
  131. const result = await runtime.run({
  132. program: 'await tools.never({}); return 1',
  133. bindings: tools({ never: () => new Promise(() => {}) }),
  134. })
  135. expect(result.error?.kind).toBe('timeout')
  136. expect(result.error?.message).toContain('wall-clock ceiling')
  137. }, 15_000)
  138. it('reports an abort mid-run and stops the worker', async () => {
  139. const { runtime } = await setup()
  140. const controller = new AbortController()
  141. setTimeout(() => { controller.abort('user-cancel') }, 150)
  142. const result = await runtime.run({ program: 'for (;;) {}', bindings: [], signal: controller.signal })
  143. expect(result.error).toEqual({ kind: 'abort', message: 'user-cancel' })
  144. }, 15_000)
  145. it('reports a pre-aborted signal without spawning', async () => {
  146. const { runtime } = await setup()
  147. const controller = new AbortController()
  148. controller.abort('too-late')
  149. const result = await runtime.run({ program: 'return 1', bindings: [], signal: controller.signal })
  150. expect(result.error).toEqual({ kind: 'abort', message: 'too-late' })
  151. })
  152. it('drops a binding resolution that lands after the run settled', async () => {
  153. const { runtime } = await setup()
  154. const controller = new AbortController()
  155. let replyDelivered!: Promise<void>
  156. const result = await runtime.run({
  157. program: 'void tools.late({}); for (;;) {}',
  158. bindings: tools({
  159. // Anchored on invocation: abort 100ms after the call reaches the
  160. // host, resolve 400ms after — by then the run has settled, so the
  161. // resolution's reply hits the post-settlement drop.
  162. late: () => new Promise((resolve) => {
  163. setTimeout(() => { controller.abort('cancel-now') }, 100)
  164. replyDelivered = new Promise(done => setTimeout(() => { resolve('too-late'); done() }, 400))
  165. }),
  166. }),
  167. signal: controller.signal,
  168. })
  169. expect(result.error).toEqual({ kind: 'abort', message: 'cancel-now' })
  170. // Let the late resolution actually fire so its reply executes instead of
  171. // being cancelled with the test.
  172. await replyDelivered
  173. }, 15_000)
  174. it('contains an OOM under resourceLimits as worker-exit, host process healthy', async () => {
  175. const { runtime } = await setup({ maxOldGenerationSizeMb: 32 })
  176. const result = await runtime.run({
  177. program: 'const hog = []; for (;;) hog.push(new Array(1e6).fill(1));',
  178. bindings: [],
  179. })
  180. expect(result.error?.kind).toBe('worker-exit')
  181. // And the host is fine: run something else.
  182. const after = await runtime.run({ program: 'return "alive"', bindings: [] })
  183. expect(after.value).toBe('alive')
  184. }, 30_000)
  185. it('truncates runaway log output at the byte budget with an in-band marker', async () => {
  186. const { runtime } = await setup({ maxLogBytes: 300 })
  187. const result = await runtime.run({
  188. program: 'for (let i = 0; i < 1000; i++) console.log("spam line", i); return 1',
  189. bindings: [],
  190. })
  191. expect(result.logs.at(-1)).toContain('truncated at 300 bytes')
  192. const total = result.logs.reduce((sum, text) => sum + Buffer.byteLength(text, 'utf8'), 0)
  193. expect(total).toBeLessThan(1_000)
  194. })
  195. it('caps an oversized return value with a truncation marker', async () => {
  196. const { runtime } = await setup({ maxValueBytes: 64 })
  197. const result = await runtime.run({ program: 'return "y".repeat(10_000)', bindings: [] })
  198. expect(result.value).toBe(`${'y'.repeat(64)}… [truncated]`)
  199. })
  200. it('caps a multibyte return value by UTF-8 bytes, not string length', async () => {
  201. // 4 code units, 12 UTF-8 bytes: a length-counting cap would let the full
  202. // string cross. The worker's byte-exact capped rendering then passes the
  203. // host re-cap unchanged (cap + marker is exactly the granted slack).
  204. const { runtime } = await setup({ maxValueBytes: 4 })
  205. const result = await runtime.run({ program: 'return "€€€€"', bindings: [] })
  206. expect(result.value).toBe('€… [truncated]')
  207. })
  208. it('completes a program that awaits its write callback, capturing the chunk', async () => {
  209. // Node's write(chunk[, encoding][, callback]) contract: dropping the
  210. // callback would leave this promise pending until the wall ceiling and
  211. // misreport a completed program as a timeout.
  212. const { runtime } = await setup({ maxWallMs: 2_000 })
  213. const result = await runtime.run({
  214. program: 'await new Promise(resolve => process.stdout.write("flushed", resolve)); return "done"',
  215. bindings: [],
  216. })
  217. expect(result.error).toBeUndefined()
  218. expect(result.value).toBe('done')
  219. expect(result.logs).toContain('flushed')
  220. })
  221. it('caps a huge container whose bounded rendering is small (wire size, not rendering, is what counts)', async () => {
  222. const { runtime } = await setup()
  223. const result = await runtime.run({ program: 'return new Array(50_000).fill(7)', bindings: [] })
  224. expect(result.error).toBeUndefined()
  225. expect(typeof result.value).toBe('string')
  226. expect(result.value).toContain('more items')
  227. })
  228. it('captures pipe writes that bypass the patched write slot as stray logs, capped by the same budget', async () => {
  229. const { runtime } = await setup({ maxLogBytes: 4 })
  230. const result = await runtime.run({
  231. // The prototype write bypasses the patched instance and reaches the real pipe. Pauses keep
  232. // writes in separate chunks and let both reach the host before settlement.
  233. program: `
  234. const write = (text) => Object.getPrototypeOf(process.stdout).write.call(process.stdout, text);
  235. write('abcd');
  236. await new Promise(resolve => setTimeout(resolve, 150));
  237. write('ef');
  238. await new Promise(resolve => setTimeout(resolve, 100));
  239. return 1;
  240. `,
  241. bindings: [],
  242. })
  243. expect(result.error).toBeUndefined()
  244. expect(result.logs).toContain('abcd')
  245. expect(result.logs).not.toContain('ef')
  246. }, 15_000)
  247. })
  248. describe('WorkerCodeRuntime — hostile programs (real workers)', () => {
  249. it('survives forged port traffic: unknown binding names, duplicate ids, junk shapes', async () => {
  250. const { runtime } = await setup()
  251. const result = await runtime.run({
  252. program: `
  253. const { parentPort } = await import('node:worker_threads');
  254. parentPort.postMessage({ type: 'call', id: 7777, global: 'tools', name: 'missing', args: {} });
  255. parentPort.postMessage({ type: 'call', id: 7777, global: 'tools', name: 'missing', args: {} });
  256. parentPort.postMessage({ type: 'call', id: 7778, global: 'tools', name: 'constructor', args: {} });
  257. parentPort.postMessage({ type: 'junk' });
  258. return await tools.real({});
  259. `,
  260. bindings: tools({ real: async () => 'still-works' }),
  261. })
  262. expect(result.error).toBeUndefined()
  263. expect(result.value).toBe('still-works')
  264. })
  265. it('survives arbitrary junk on the port: non-objects, junk types, malformed calls, logs, and dones', async () => {
  266. const { runtime } = await setup()
  267. const result = await runtime.run({
  268. program: `
  269. const { parentPort } = await import('node:worker_threads');
  270. for (const junk of [
  271. null, 42, 'junk', [],
  272. { type: 'nope' },
  273. { type: 'call' },
  274. { type: 'call', id: 'x', global: 'tools', name: 'real', args: {} },
  275. { type: 'call', id: 1e9, global: 7, name: 'real', args: {} },
  276. { type: 'call', id: 1e9, global: 'tools', name: 7, args: {} },
  277. { type: 'log' },
  278. { type: 'log', text: null },
  279. { type: 'log', text: 7 },
  280. { type: 'log', text: {} },
  281. { type: 'done', error: 5 },
  282. { type: 'done', error: { message: 5 } },
  283. ]) parentPort.postMessage(junk);
  284. return await tools.real({});
  285. `,
  286. bindings: tools({ real: async () => 'still-works' }),
  287. })
  288. expect(result.error).toBeUndefined()
  289. expect(result.value).toBe('still-works')
  290. expect(result.logs).toEqual([])
  291. })
  292. it('caps forged log floods and forged done values at the configured budgets, dropping forged extra fields', async () => {
  293. const { runtime } = await setup({ maxLogBytes: 200, maxValueBytes: 64 })
  294. const result = await runtime.run({
  295. // Forged messages bypass the worker-side LogBuffer and prepareValue
  296. // entirely — only the host-side ledger and re-cap stand between model
  297. // code and an unbounded result.
  298. program: `
  299. const { parentPort } = await import('node:worker_threads');
  300. for (let i = 0; i < 50; i++) parentPort.postMessage({ type: 'log', text: 'F'.repeat(100), forged: true });
  301. parentPort.postMessage({ type: 'done', value: 'V'.repeat(100000) });
  302. for (;;) {}
  303. `,
  304. bindings: [],
  305. })
  306. expect(typeof result.value).toBe('string')
  307. const value = result.value as string
  308. expect(value.startsWith('V'.repeat(64))).toBe(true)
  309. expect(value.endsWith('… [truncated]')).toBe(true)
  310. expect(value.length).toBeLessThan(120)
  311. const marker = '[dsh-code-runtime-worker] log capture truncated at 200 bytes'
  312. const total = result.logs.reduce((sum, text) => sum + Buffer.byteLength(text, 'utf8'), 0)
  313. expect(total).toBeLessThanOrEqual(200 + Buffer.byteLength(marker, 'utf8'))
  314. expect(result.logs.at(-1)).toBe(marker)
  315. })
  316. it('accepts a forged done carrying both value and error (self-sabotage, contained)', async () => {
  317. const { runtime } = await setup()
  318. const result = await runtime.run({
  319. program: `
  320. const { parentPort } = await import('node:worker_threads');
  321. parentPort.postMessage({ type: 'done', value: 'lied', error: { message: 'fake failure' } });
  322. for (;;) {}
  323. `,
  324. bindings: [],
  325. })
  326. expect(result.value).toBe('lied')
  327. expect(result.error).toEqual({ kind: 'exception', message: 'fake failure' })
  328. })
  329. it('byte-bounds forged multibyte error text at the host', async () => {
  330. // Forged error text bypasses the worker entirely; the host bound is a
  331. // BYTE bound (two € = 6 bytes fit an 8-byte cap, a third would not).
  332. const { runtime } = await setup({ maxValueBytes: 8 })
  333. const result = await runtime.run({
  334. program: `
  335. const { parentPort } = await import('node:worker_threads');
  336. parentPort.postMessage({ type: 'done', error: { message: '€'.repeat(1000) } });
  337. for (;;) {}
  338. `,
  339. bindings: [],
  340. })
  341. expect(result.error).toEqual({ kind: 'exception', message: '€€' })
  342. })
  343. it('answers a binding whose resolution cannot be cloned with a failure reply', async () => {
  344. const { runtime } = await setup()
  345. const result = await runtime.run({
  346. program: 'try { await tools.bad({}) } catch (error) { return error.message }',
  347. bindings: tools({ bad: async () => (() => 1) }),
  348. })
  349. expect(result.value).toContain('not structured-cloneable')
  350. })
  351. it('exposes binding names that collide with Object.prototype as ordinary functions', async () => {
  352. const { runtime } = await setup()
  353. const result = await runtime.run({
  354. program: 'return [await tools["__proto__"]({}), await tools["constructor"]({}), typeof tools["hasOwnProperty"]]',
  355. // Computed keys: a literal `'__proto__': …` entry would SET the record's
  356. // prototype instead of declaring a binding of that name.
  357. bindings: tools({ ['__proto__']: async () => 'proto-ok', ['constructor']: async () => 'ctor-ok' }),
  358. })
  359. expect(result.value).toEqual(['proto-ok', 'ctor-ok', 'undefined'])
  360. })
  361. })
  362. describe('WorkerCodeRuntime — seam misuse and lifecycle', () => {
  363. it('rejects invalid binding globals loudly (identifier, reserved word, duplicate, console)', async () => {
  364. const { runtime } = await setup()
  365. const cases: [string, RegExp][] = [
  366. ['not valid!', /not a usable identifier/],
  367. ['await', /not a usable identifier/],
  368. ['console', /duplicate binding global/],
  369. ]
  370. for (const [global, message] of cases) {
  371. await expect(runtime.run({ program: 'return 1', bindings: [{ global, functions: {} }] })).rejects.toThrow(message)
  372. }
  373. await expect(runtime.run({
  374. program: 'return 1',
  375. bindings: [{ global: 'tools', functions: {} }, { global: 'tools', functions: {} }],
  376. })).rejects.toThrow(/duplicate binding global/)
  377. })
  378. it('rejects config values that are not positive numbers', async () => {
  379. const ctx = new Context()
  380. await expect(ctx.plugin(WorkerCodeRuntime, { computeMs: -1 })).rejects.toThrow(/positive number/)
  381. })
  382. it('keeps runs isolated: no state survives from one run to the next', async () => {
  383. const { runtime } = await setup()
  384. await runtime.run({ program: 'globalThis.leak = "value"; return 1', bindings: [] })
  385. const second = await runtime.run({ program: 'return typeof globalThis.leak', bindings: [] })
  386. expect(second.value).toBe('undefined')
  387. })
  388. it('disposal aborts in-flight runs, awaits worker exit, and rejects later runs', async () => {
  389. const ctx = new Context()
  390. const fiber = await ctx.plugin(WorkerCodeRuntime)
  391. const runtime = ctx.codeRuntime as WorkerCodeRuntime
  392. const inflight: Promise<CodeRunResult> = runtime.run({ program: 'for (;;) {}', bindings: [] })
  393. // Give the worker a moment to actually start spinning.
  394. await new Promise(resolve => setTimeout(resolve, 200))
  395. await fiber.dispose()
  396. const result = await inflight
  397. expect(result.error).toEqual({ kind: 'abort', message: 'runtime disposed' })
  398. await expect(runtime.run({ program: 'return 1', bindings: [] })).rejects.toThrow(/after disposal/)
  399. }, 15_000)
  400. it('removes ctx.codeRuntime when the providing fiber disposes (HMR safety)', async () => {
  401. const ctx = new Context()
  402. const fiber = await ctx.plugin(WorkerCodeRuntime)
  403. expect(ctx.get('codeRuntime')).toBeInstanceOf(WorkerCodeRuntime)
  404. await fiber.dispose()
  405. expect(ctx.get('codeRuntime')).toBeUndefined()
  406. })
  407. })