bootstrap.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. /**
  2. * Worker-side execution logic, written as plain functions over an injected
  3. * port so the unit suite can run every line IN-PROCESS against a fake port
  4. * (a real worker thread is a separate V8 isolate the coverage provider
  5. * cannot observe). The real worker entry (`worker.ts`) is a thin
  6. * self-executing glue file over {@link runWorkerMain}, excluded from
  7. * coverage the same way `bin.ts` entrypoints are, and exercised end-to-end
  8. * by the integration tests that spawn real workers.
  9. *
  10. * @module @deepseek-ai/dsh-code-runtime-worker/src/bootstrap
  11. */
  12. import { inspect } from 'node:util'
  13. import type { CodeLogEntry } from '@deepseek-ai/dsh-code-runtime'
  14. import type { DoneMessage, ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
  15. /** The port surface the bootstrap needs — satisfied by `parentPort` and by the tests' fake. */
  16. export interface BootstrapPort {
  17. postMessage(message: WorkerToHost): void
  18. on(event: 'message', listener: (message: ReplyMessage) => void): void
  19. }
  20. /**
  21. * A writable stream's `write` slot, as the bootstrap patches it (see
  22. * {@link captureStreamWrites}). Method-typed so the real
  23. * `process.stdout`/`process.stderr` (narrower chunk parameters) remain
  24. * assignable.
  25. */
  26. export interface PatchableStream {
  27. write(chunk: unknown, ...rest: unknown[]): boolean
  28. }
  29. /**
  30. * Ordered log capture under one shared byte budget, delivered to a sink as
  31. * each entry lands (the real sink streams entries over the port eagerly, so
  32. * captured output survives a mid-run termination). Once the budget is
  33. * exhausted it emits exactly one in-band marker entry (on the `stderr`
  34. * diagnostics channel) and silently drops everything after — the cap is a
  35. * blast-radius bound, so "how much was lost" intentionally stays unmeasured.
  36. */
  37. export class LogBuffer {
  38. private remaining: number
  39. private truncated = false
  40. // Explicit fields, not constructor parameter properties: this module loads
  41. // under Node's native strip-only mode, which rejects non-erasable syntax —
  42. // and parameter properties are non-erasable.
  43. private readonly maxBytes: number
  44. private readonly sink: (entry: CodeLogEntry) => void
  45. constructor(maxBytes: number, sink: (entry: CodeLogEntry) => void) {
  46. this.maxBytes = maxBytes
  47. this.sink = sink
  48. this.remaining = maxBytes
  49. }
  50. /**
  51. * Emit one entry to the sink, charging its text against the budget (drops + marks once exhausted).
  52. * @param entry - the log entry to deliver.
  53. */
  54. push(entry: CodeLogEntry): void {
  55. if (this.truncated) return
  56. const cost = Buffer.byteLength(entry.text, 'utf8')
  57. if (cost > this.remaining) {
  58. this.truncated = true
  59. this.sink({ source: 'stderr', text: `[dsh-code-runtime-worker] log capture truncated at ${this.maxBytes} bytes` })
  60. return
  61. }
  62. this.remaining -= cost
  63. this.sink(entry)
  64. }
  65. }
  66. /** The five console methods the shim captures, in the seam's level vocabulary. */
  67. const CONSOLE_LEVELS = ['log', 'info', 'warn', 'error', 'debug'] as const
  68. /**
  69. * A `console` replacement whose five leveled methods render their arguments
  70. * `util.inspect`-style (matching real console formatting closely enough for
  71. * a model to recognize its own output) into the buffer. Only these five
  72. * exist — the program gets a deliberately small console, not Node's full
  73. * surface.
  74. * @param logs - the buffer every rendered line is pushed into.
  75. * @returns the five-method console object handed to the program.
  76. */
  77. export function makeConsoleShim(logs: LogBuffer): Record<(typeof CONSOLE_LEVELS)[number], (...args: unknown[]) => void> {
  78. const render = (args: unknown[]): string =>
  79. args.map(arg => typeof arg === 'string' ? arg : inspect(arg, INSPECT_OPTIONS)).join(' ')
  80. const shim = Object.create(null) as Record<(typeof CONSOLE_LEVELS)[number], (...args: unknown[]) => void>
  81. for (const level of CONSOLE_LEVELS) {
  82. shim[level] = (...args: unknown[]) => { logs.push({ source: 'console', level, text: render(args) }) }
  83. }
  84. return shim
  85. }
  86. /**
  87. * Redirect a stream's `write` into the log buffer (the program-visible
  88. * `process.stdout`/`process.stderr` in the real worker), so raw writes land
  89. * in emission order alongside console output instead of racing down a pipe.
  90. * @param logs - the buffer captured writes are pushed into.
  91. * @param stream - the stream whose `write` slot is patched.
  92. * @param source - the log source the captured writes are attributed to.
  93. * @returns the restore function (the in-process tests un-patch; the real
  94. * worker never needs to).
  95. */
  96. export function captureStreamWrites(logs: LogBuffer, stream: PatchableStream, source: 'stdout' | 'stderr'): () => void {
  97. // The slot's VALUE is stored for restore and reassigned — never invoked
  98. // detached, so the unbound-method concern does not apply.
  99. // eslint-disable-next-line @typescript-eslint/unbound-method
  100. const original = stream.write
  101. stream.write = (chunk: unknown): boolean => {
  102. logs.push({ source, text: typeof chunk === 'string' ? chunk : String(chunk) })
  103. return true
  104. }
  105. return () => { stream.write = original }
  106. }
  107. /** Bounded inspect options: deep enough to be useful, bounded so a pathological value cannot explode the rendering. */
  108. const INSPECT_OPTIONS = { depth: 4, maxArrayLength: 100, maxStringLength: 10_000 } as const
  109. /**
  110. * Prepare the program's completion value for the done message: a
  111. * structured-clone-safe value whose rendering fits `maxValueBytes` crosses
  112. * raw; anything else (non-cloneable, or oversized) is REPLACED by its
  113. * bounded `util.inspect` rendering, truncated with an in-band marker — the
  114. * seam contract's "a non-transferable value is replaced by a string
  115. * rendering", extended to oversized ones so a huge return cannot flood the
  116. * host.
  117. * @param value - the program's completion value.
  118. * @param maxValueBytes - the byte cap for the rendered value.
  119. * @returns the done-message fragment: `{}` for `undefined`, else `{ value }`.
  120. */
  121. export function prepareValue(value: unknown, maxValueBytes: number): { value?: unknown } {
  122. if (value === undefined) return {}
  123. const rendered = typeof value === 'string' ? value : inspect(value, INSPECT_OPTIONS)
  124. let cloneable = true
  125. try {
  126. structuredClone(value)
  127. } catch {
  128. // Only the verdict matters: the value has parts structured clone rejects
  129. // (functions, classes, …) and must cross as its rendering instead.
  130. cloneable = false
  131. }
  132. if (cloneable && Buffer.byteLength(rendered, 'utf8') <= maxValueBytes) return { value }
  133. const capped = rendered.length > maxValueBytes ? `${rendered.slice(0, maxValueBytes)}… [truncated]` : rendered
  134. return { value: capped }
  135. }
  136. /** One awaited binding call's settlement handles, keyed by call id in the pending map. */
  137. export interface PendingCall {
  138. resolve(value: unknown): void
  139. reject(error: Error): void
  140. }
  141. /**
  142. * Route host replies into the pending-call map: each reply settles its call
  143. * at most once, and a reply for an unknown id (stray, or a duplicate answer
  144. * to an id already settled) is ignored. Shared wiring between
  145. * {@link runWorkerMain} and the tests that exercise {@link makeNamespaces}
  146. * standalone.
  147. * @param port - the port whose `message` events carry the replies.
  148. * @param pending - the id-keyed map of unsettled binding calls.
  149. */
  150. export function wireReplies(port: BootstrapPort, pending: Map<number, PendingCall>): void {
  151. port.on('message', (message: ReplyMessage) => {
  152. const entry = pending.get(message.id)
  153. if (!entry) return
  154. pending.delete(message.id)
  155. if (message.ok) entry.resolve(message.value)
  156. else entry.reject(new Error(message.message))
  157. })
  158. }
  159. /**
  160. * Build the binding namespace objects the program sees: one null-prototype
  161. * global per namespace, each declared name an own enumerable async function
  162. * that bridges over the port (`__proto__`/`constructor`/`toString` are
  163. * ordinary keys, never prototype collisions). A non-cloneable argument
  164. * rejects that one call with a descriptive error; the host's reply (`ok`
  165. * false) rejects it likewise, so a failed tool call surfaces in the program
  166. * as an ordinary promise rejection.
  167. * @param data - the boot payload's namespace declarations (globals + names).
  168. * @param port - the port binding calls are posted to.
  169. * @param pending - the id-keyed map each posted call parks its handles in.
  170. * @param nextId - the shared mutable id counter (worker-issued correlation ids).
  171. * @returns one namespace object per declaration, in declaration order.
  172. */
  173. export function makeNamespaces(
  174. data: Pick<WorkerBootData, 'namespaces'>,
  175. port: BootstrapPort,
  176. pending: Map<number, PendingCall>,
  177. nextId: { value: number },
  178. ): Record<string, unknown>[] {
  179. return data.namespaces.map(({ global, names }) => {
  180. const namespace = Object.create(null) as Record<string, unknown>
  181. for (const name of names) {
  182. Object.defineProperty(namespace, name, {
  183. enumerable: true,
  184. value: (args: unknown): Promise<unknown> => new Promise((resolve, reject) => {
  185. const id = nextId.value++
  186. pending.set(id, { resolve, reject })
  187. try {
  188. port.postMessage({ type: 'call', id, global, name, args })
  189. } catch (error: unknown) {
  190. pending.delete(id)
  191. reject(new Error(`binding arguments must be structured-cloneable: ${error instanceof Error ? error.message : String(error)}`))
  192. }
  193. }),
  194. })
  195. }
  196. return namespace
  197. })
  198. }
  199. /**
  200. * Run one program to settlement and post the {@link DoneMessage}: wires the
  201. * reply handler, materializes the namespaces and console shim, compiles the
  202. * type-stripped body as an async function (top-level `await`/`return`
  203. * work), and reports a thrown program error as the done message's `error`
  204. * field. Exactly one done message is ever posted.
  205. * @param port - the message port to the host (the real `parentPort`, or the tests' fake).
  206. * @param data - the boot payload the host sent.
  207. * @param streams - the stream objects whose `write` is captured (the real
  208. * `process.stdout`/`process.stderr` in the worker; fakes in tests).
  209. * @returns resolves after the done message is posted (the tests await it;
  210. * the real entry lets the worker exit naturally).
  211. */
  212. export async function runWorkerMain(
  213. port: BootstrapPort,
  214. data: WorkerBootData,
  215. streams: { stdout: PatchableStream; stderr: PatchableStream },
  216. ): Promise<void> {
  217. const logs = new LogBuffer(data.maxLogBytes, (entry) => { port.postMessage({ type: 'log', entry }) })
  218. captureStreamWrites(logs, streams.stdout, 'stdout')
  219. captureStreamWrites(logs, streams.stderr, 'stderr')
  220. const pending = new Map<number, PendingCall>()
  221. wireReplies(port, pending)
  222. const nextId = { value: 1 }
  223. const namespaces = makeNamespaces(data, port, pending, nextId)
  224. const consoleShim = makeConsoleShim(logs)
  225. let done: DoneMessage
  226. try {
  227. // The async function constructor, reached through an instance because
  228. // `AsyncFunction` is not a global. The program body is strict-mode.
  229. /* v8 ignore next -- the arrow exists only to reach the AsyncFunction constructor; it is never invoked. */
  230. const AsyncFunction = (async () => {}).constructor as new (...args: string[]) => (...fnArgs: unknown[]) => Promise<unknown>
  231. const fn = new AsyncFunction(...data.namespaces.map(namespace => namespace.global), 'console', `'use strict';\n${data.code}`)
  232. const value = await fn(...namespaces, consoleShim)
  233. done = { type: 'done', ...prepareValue(value, data.maxValueBytes) }
  234. } catch (error: unknown) {
  235. const message = error instanceof Error ? error.stack ?? error.message : String(error)
  236. done = { type: 'done', error: { message } }
  237. }
  238. port.postMessage(done)
  239. }