runtime.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  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 console/stdout 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.map(entry => [entry.source, entry.level ?? null])).toEqual([
  43. ['console', 'log'],
  44. ['stdout', null],
  45. ['console', 'warn'],
  46. ])
  47. expect(result.logs[0]?.text).toBe('point { x: 1, y: 2 }')
  48. })
  49. it('bridges binding calls both ways and rejects the program-side call on a host rejection', async () => {
  50. const { runtime } = await setup()
  51. const calls: unknown[] = []
  52. const result = await runtime.run({
  53. program: `
  54. const first = await tools.echo({ n: 1 });
  55. let caught = '';
  56. try { await tools.fail({}) } catch (error) { caught = error.message }
  57. let caughtRaw = '';
  58. try { await tools.failRaw({}) } catch (error) { caughtRaw = error.message }
  59. return { first, caught, caughtRaw };
  60. `,
  61. bindings: tools({
  62. echo: async (args) => { calls.push(args); return { echoed: args } },
  63. fail: async () => { throw new Error('nope') },
  64. // A non-Error throw: the host renders it, the program still catches.
  65. failRaw: async () => { throw 'raw-nope' },
  66. }),
  67. })
  68. expect(result.error).toBeUndefined()
  69. expect(result.value).toEqual({ first: { echoed: { n: 1 } }, caught: 'nope', caughtRaw: 'raw-nope' })
  70. expect(calls).toEqual([{ n: 1 }])
  71. })
  72. it('reports non-erasable syntax as an exception without spawning a worker', async () => {
  73. const { runtime } = await setup()
  74. const result = await runtime.run({ program: 'enum E { A }\nreturn 1', bindings: [] })
  75. expect(result.error?.kind).toBe('exception')
  76. expect(result.error?.message).toMatch(/enum|strip/i)
  77. })
  78. it('reports a runtime throw as an exception with the message', async () => {
  79. const { runtime } = await setup()
  80. const result = await runtime.run({ program: 'throw new Error("kaboom")', bindings: [] })
  81. expect(result.error?.kind).toBe('exception')
  82. expect(result.error?.message).toContain('kaboom')
  83. })
  84. it('gives the program an EMPTY environment', async () => {
  85. const { runtime } = await setup()
  86. const result = await runtime.run({ program: 'return JSON.stringify(process.env)', bindings: [] })
  87. expect(result.value).toBe('{}')
  88. })
  89. it('replaces a non-cloneable return value with a string rendering', async () => {
  90. const { runtime } = await setup()
  91. const result = await runtime.run({ program: 'return { f: () => 1 }', bindings: [] })
  92. expect(typeof result.value).toBe('string')
  93. })
  94. it('keeps logs streamed before a failure', async () => {
  95. const { runtime } = await setup()
  96. const result = await runtime.run({
  97. program: 'console.log("before"); throw new Error("after-log")',
  98. bindings: [],
  99. })
  100. expect(result.error?.kind).toBe('exception')
  101. expect(result.logs.map(entry => entry.text)).toContain('before')
  102. })
  103. })
  104. describe('WorkerCodeRuntime — budgets and containment (real workers)', () => {
  105. it('ends a hot loop at the compute budget — including behind a pending decoy dispatch', async () => {
  106. const { runtime } = await setup({ computeMs: 300, maxWallMs: 30_000 })
  107. const result = await runtime.run({
  108. // The decoy: fire a call at a never-resolving binding WITHOUT awaiting,
  109. // then spin. Host-side pending-call bookkeeping would pause a naive
  110. // budget here; measured busy time cannot be fooled.
  111. program: 'void tools.slow({}); for (;;) {}',
  112. bindings: tools({ slow: () => new Promise(() => {}) }),
  113. })
  114. expect(result.error?.kind).toBe('timeout')
  115. expect(result.error?.message).toContain('compute budget')
  116. }, 15_000)
  117. it('does not charge time spent awaiting a slow binding against the compute budget', async () => {
  118. const { runtime } = await setup({ computeMs: 250, maxWallMs: 30_000 })
  119. const result = await runtime.run({
  120. program: 'return await tools.slow({})',
  121. bindings: tools({ slow: () => new Promise(resolve => setTimeout(() => { resolve('slow-done') }, 700)) }),
  122. })
  123. expect(result.error).toBeUndefined()
  124. expect(result.value).toBe('slow-done')
  125. }, 15_000)
  126. it('ends an idle-forever run at the wall-clock ceiling', async () => {
  127. const { runtime } = await setup({ computeMs: 30_000, maxWallMs: 400 })
  128. const result = await runtime.run({
  129. program: 'await tools.never({}); return 1',
  130. bindings: tools({ never: () => new Promise(() => {}) }),
  131. })
  132. expect(result.error?.kind).toBe('timeout')
  133. expect(result.error?.message).toContain('wall-clock ceiling')
  134. }, 15_000)
  135. it('reports an abort mid-run and stops the worker', async () => {
  136. const { runtime } = await setup()
  137. const controller = new AbortController()
  138. setTimeout(() => { controller.abort('user-cancel') }, 150)
  139. const result = await runtime.run({ program: 'for (;;) {}', bindings: [], signal: controller.signal })
  140. expect(result.error).toEqual({ kind: 'abort', message: 'user-cancel' })
  141. }, 15_000)
  142. it('reports a pre-aborted signal without spawning', async () => {
  143. const { runtime } = await setup()
  144. const controller = new AbortController()
  145. controller.abort('too-late')
  146. const result = await runtime.run({ program: 'return 1', bindings: [], signal: controller.signal })
  147. expect(result.error).toEqual({ kind: 'abort', message: 'too-late' })
  148. })
  149. it('drops a binding resolution that lands after the run settled', async () => {
  150. const { runtime } = await setup()
  151. const controller = new AbortController()
  152. let replyDelivered!: Promise<void>
  153. const result = await runtime.run({
  154. program: 'void tools.late({}); for (;;) {}',
  155. bindings: tools({
  156. // Anchored on invocation: abort 100ms after the call reaches the
  157. // host, resolve 400ms after — by then the run has settled, so the
  158. // resolution's reply hits the post-settlement drop.
  159. late: () => new Promise((resolve) => {
  160. setTimeout(() => { controller.abort('cancel-now') }, 100)
  161. replyDelivered = new Promise(done => setTimeout(() => { resolve('too-late'); done() }, 400))
  162. }),
  163. }),
  164. signal: controller.signal,
  165. })
  166. expect(result.error).toEqual({ kind: 'abort', message: 'cancel-now' })
  167. // Let the late resolution actually fire so its reply executes instead of
  168. // being cancelled with the test.
  169. await replyDelivered
  170. }, 15_000)
  171. it('contains an OOM under resourceLimits as worker-exit, host process healthy', async () => {
  172. const { runtime } = await setup({ maxOldGenerationSizeMb: 32 })
  173. const result = await runtime.run({
  174. program: 'const hog = []; for (;;) hog.push(new Array(1e6).fill(1));',
  175. bindings: [],
  176. })
  177. expect(result.error?.kind).toBe('worker-exit')
  178. // And the host is fine: run something else.
  179. const after = await runtime.run({ program: 'return "alive"', bindings: [] })
  180. expect(after.value).toBe('alive')
  181. }, 30_000)
  182. it('truncates runaway log output at the byte budget with an in-band marker', async () => {
  183. const { runtime } = await setup({ maxLogBytes: 300 })
  184. const result = await runtime.run({
  185. program: 'for (let i = 0; i < 1000; i++) console.log("spam line", i); return 1',
  186. bindings: [],
  187. })
  188. expect(result.logs.at(-1)?.text).toContain('truncated at 300 bytes')
  189. const total = result.logs.reduce((sum, entry) => sum + Buffer.byteLength(entry.text, 'utf8'), 0)
  190. expect(total).toBeLessThan(1_000)
  191. })
  192. it('caps an oversized return value with a truncation marker', async () => {
  193. const { runtime } = await setup({ maxValueBytes: 64 })
  194. const result = await runtime.run({ program: 'return "y".repeat(10_000)', bindings: [] })
  195. expect(result.value).toBe(`${'y'.repeat(64)}… [truncated]`)
  196. })
  197. it('captures pipe writes that bypass the patched write slot as stray logs, capped by the same budget', async () => {
  198. const { runtime } = await setup({ maxLogBytes: 4 })
  199. const result = await runtime.run({
  200. // The bootstrap patches the stream instance's own `write`; going
  201. // through the prototype's slot reaches the real pipe underneath, so
  202. // the bytes arrive host-side as stray data. The pauses keep the two
  203. // writes in separate pipe chunks and let them land before settlement.
  204. program: `
  205. const write = (text) => Object.getPrototypeOf(process.stdout).write.call(process.stdout, text);
  206. write('abcd');
  207. await new Promise(resolve => setTimeout(resolve, 150));
  208. write('ef');
  209. await new Promise(resolve => setTimeout(resolve, 100));
  210. return 1;
  211. `,
  212. bindings: [],
  213. })
  214. expect(result.error).toBeUndefined()
  215. expect(result.logs).toContainEqual({ source: 'stdout', text: 'abcd' })
  216. expect(result.logs.map(entry => entry.text)).not.toContain('ef')
  217. }, 15_000)
  218. })
  219. describe('WorkerCodeRuntime — hostile programs (real workers)', () => {
  220. it('survives forged port traffic: unknown binding names, duplicate ids, junk shapes', async () => {
  221. const { runtime } = await setup()
  222. const result = await runtime.run({
  223. program: `
  224. const { parentPort } = await import('node:worker_threads');
  225. parentPort.postMessage({ type: 'call', id: 7777, global: 'tools', name: 'missing', args: {} });
  226. parentPort.postMessage({ type: 'call', id: 7777, global: 'tools', name: 'missing', args: {} });
  227. parentPort.postMessage({ type: 'call', id: 7778, global: 'tools', name: 'constructor', args: {} });
  228. parentPort.postMessage({ type: 'junk' });
  229. return await tools.real({});
  230. `,
  231. bindings: tools({ real: async () => 'still-works' }),
  232. })
  233. expect(result.error).toBeUndefined()
  234. expect(result.value).toBe('still-works')
  235. })
  236. it('answers a binding whose resolution cannot be cloned with a failure reply', async () => {
  237. const { runtime } = await setup()
  238. const result = await runtime.run({
  239. program: 'try { await tools.bad({}) } catch (error) { return error.message }',
  240. bindings: tools({ bad: async () => (() => 1) }),
  241. })
  242. expect(result.value).toContain('not structured-cloneable')
  243. })
  244. it('exposes binding names that collide with Object.prototype as ordinary functions', async () => {
  245. const { runtime } = await setup()
  246. const result = await runtime.run({
  247. program: 'return [await tools["__proto__"]({}), await tools["constructor"]({}), typeof tools["hasOwnProperty"]]',
  248. // Computed keys: a literal `'__proto__': …` entry would SET the record's
  249. // prototype instead of declaring a binding of that name.
  250. bindings: tools({ ['__proto__']: async () => 'proto-ok', ['constructor']: async () => 'ctor-ok' }),
  251. })
  252. expect(result.value).toEqual(['proto-ok', 'ctor-ok', 'undefined'])
  253. })
  254. })
  255. describe('WorkerCodeRuntime — seam misuse and lifecycle', () => {
  256. it('rejects invalid binding globals loudly (identifier, reserved word, duplicate, console)', async () => {
  257. const { runtime } = await setup()
  258. const cases: [string, RegExp][] = [
  259. ['not valid!', /not a usable identifier/],
  260. ['await', /not a usable identifier/],
  261. ['console', /duplicate binding global/],
  262. ]
  263. for (const [global, message] of cases) {
  264. await expect(runtime.run({ program: 'return 1', bindings: [{ global, functions: {} }] })).rejects.toThrow(message)
  265. }
  266. await expect(runtime.run({
  267. program: 'return 1',
  268. bindings: [{ global: 'tools', functions: {} }, { global: 'tools', functions: {} }],
  269. })).rejects.toThrow(/duplicate binding global/)
  270. })
  271. it('rejects config values that are not positive numbers', async () => {
  272. const ctx = new Context()
  273. await expect(ctx.plugin(WorkerCodeRuntime, { computeMs: -1 })).rejects.toThrow(/positive number/)
  274. })
  275. it('keeps runs isolated: no state survives from one run to the next', async () => {
  276. const { runtime } = await setup()
  277. await runtime.run({ program: 'globalThis.leak = "value"; return 1', bindings: [] })
  278. const second = await runtime.run({ program: 'return typeof globalThis.leak', bindings: [] })
  279. expect(second.value).toBe('undefined')
  280. })
  281. it('disposal aborts in-flight runs, awaits worker exit, and rejects later runs', async () => {
  282. const ctx = new Context()
  283. const fiber = await ctx.plugin(WorkerCodeRuntime)
  284. const runtime = ctx.codeRuntime as WorkerCodeRuntime
  285. const inflight: Promise<CodeRunResult> = runtime.run({ program: 'for (;;) {}', bindings: [] })
  286. // Give the worker a moment to actually start spinning.
  287. await new Promise(resolve => setTimeout(resolve, 200))
  288. await fiber.dispose()
  289. const result = await inflight
  290. expect(result.error).toEqual({ kind: 'abort', message: 'runtime disposed' })
  291. await expect(runtime.run({ program: 'return 1', bindings: [] })).rejects.toThrow(/after disposal/)
  292. }, 15_000)
  293. it('removes ctx.codeRuntime when the providing fiber disposes (HMR safety)', async () => {
  294. const ctx = new Context()
  295. const fiber = await ctx.plugin(WorkerCodeRuntime)
  296. expect(ctx.get('codeRuntime')).toBeInstanceOf(WorkerCodeRuntime)
  297. await fiber.dispose()
  298. expect(ctx.get('codeRuntime')).toBeUndefined()
  299. })
  300. })