bootstrap.ts 12 KB

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