bootstrap.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424
  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-thread/src/bootstrap
  6. */
  7. import { inspect } from 'node:util'
  8. import type { DoneMessage, ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
  9. import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from './output-json.ts'
  10. import { decodeWorkerJson, encodeWorkerJson, snapshotCodeJsonValue } from './worker-json.ts'
  11. const CapturedError = Error
  12. const capturedObjectCreate = Object.create
  13. const capturedObjectDefineProperty = Object.defineProperty
  14. /** Define one public binding-error field without consulting mutable globals or descriptor prototypes. */
  15. function defineBindingErrorField(error: Error, key: string, value: string): void {
  16. const attributes = capturedObjectCreate(null) as PropertyDescriptor
  17. attributes.enumerable = true
  18. attributes.value = value
  19. capturedObjectDefineProperty(error, key, attributes)
  20. }
  21. /** The port API the bootstrap needs — satisfied by `parentPort` and by the tests' fake. */
  22. export interface BootstrapPort {
  23. postMessage(message: WorkerToHost): void
  24. on(event: 'message', listener: (message: ReplyMessage) => void): void
  25. }
  26. /**
  27. * A writable stream's `write` slot, as the bootstrap patches it (see
  28. * {@link captureStreamWrites}). Method-typed so the real
  29. * `process.stdout`/`process.stderr` (narrower chunk parameters) remain
  30. * assignable.
  31. */
  32. export interface PatchableStream {
  33. write(chunk: unknown, ...rest: unknown[]): boolean
  34. }
  35. /**
  36. * Ordered text capture under the shared outer JSON-byte budget, delivered to
  37. * a sink as each item lands (the real sink streams text over the port eagerly,
  38. * so captured output survives a mid-run termination). It includes the log
  39. * array syntax and string escaping in its accounting. Once exhausted it emits
  40. * the fitting prefix and reports the limit once; the host turns that condition
  41. * into an explicit `output-limit` run failure.
  42. */
  43. export class LogBuffer {
  44. private bytes = 2 // JSON serialization of the empty logs array: []
  45. private entries = 0
  46. private truncated = false
  47. // Explicit fields, not constructor parameter properties: this module loads
  48. // under Node's native strip-only mode, which rejects non-erasable syntax —
  49. // and parameter properties are non-erasable.
  50. private readonly sink: (text: string) => void
  51. private readonly onLimit: () => void
  52. private readonly maxBytes: number
  53. constructor(maxBytes: number, sink: (text: string) => void, onLimit: () => void = () => {}) {
  54. this.maxBytes = maxBytes
  55. this.sink = sink
  56. this.onLimit = onLimit
  57. }
  58. /**
  59. * Emit text to the sink, charging it against the budget (drops + marks once exhausted).
  60. * @param text - the captured text to deliver.
  61. */
  62. push(text: string): void {
  63. if (this.truncated) return
  64. const separatorBytes = this.entries > 0 ? 1 : 0
  65. const availableBytes = this.maxBytes - this.bytes - separatorBytes
  66. const stringBytes = jsonStringBytesUpTo(text, availableBytes)
  67. if (stringBytes === undefined) {
  68. this.truncated = true
  69. const prefix = truncateJsonStringBytes(text, availableBytes)
  70. if (prefix.length > 0) {
  71. const prefixBytes = jsonStringBytesUpTo(prefix, availableBytes)
  72. /* v8 ignore next -- truncateJsonStringBytes guarantees the returned prefix fits. */
  73. if (prefixBytes === undefined) throw new CapturedError('worker output ledger produced an oversized log prefix')
  74. this.bytes += prefixBytes + separatorBytes
  75. this.entries += 1
  76. this.sink(prefix)
  77. }
  78. this.onLimit()
  79. return
  80. }
  81. this.bytes += stringBytes + separatorBytes
  82. this.entries += 1
  83. this.sink(text)
  84. }
  85. /** Remaining exact JSON-byte budget for the completion value or failure message. */
  86. remainingOutputBytes(): number {
  87. return this.maxBytes - this.bytes
  88. }
  89. }
  90. /** The five console methods the shim captures, in the seam's level vocabulary. */
  91. const CONSOLE_LEVELS = ['log', 'info', 'warn', 'error', 'debug'] as const
  92. /**
  93. * A `console` replacement whose five leveled methods render their arguments
  94. * `util.inspect`-style (matching real console formatting closely enough for
  95. * a model to recognize its own output) into the buffer. Only these five
  96. * exist — the program gets a deliberately small console, not Node's full
  97. * console API.
  98. * @param logs - the buffer every rendered line is pushed into.
  99. * @returns the five-method console object handed to the program.
  100. */
  101. export function makeConsoleShim(logs: LogBuffer): Record<(typeof CONSOLE_LEVELS)[number], (...args: unknown[]) => void> {
  102. const render = (args: unknown[]): string =>
  103. args.map(arg => typeof arg === 'string' ? arg : inspect(arg, INSPECT_OPTIONS)).join(' ')
  104. const shim = Object.create(null) as Record<(typeof CONSOLE_LEVELS)[number], (...args: unknown[]) => void>
  105. for (const level of CONSOLE_LEVELS) {
  106. shim[level] = (...args: unknown[]) => { logs.push(render(args)) }
  107. }
  108. return shim
  109. }
  110. /**
  111. * Redirect a stream's `write` into the log buffer (the program-visible
  112. * `process.stdout`/`process.stderr` in the real worker), so raw writes land in emission order
  113. * alongside console output instead of racing down a pipe. It preserves Node's optional callback
  114. * contract: the callback runs asynchronously after admission, even when the log budget drops
  115. * the write.
  116. *
  117. * @param logs - the buffer captured writes are pushed into.
  118. * @param stream - the stream whose `write` slot is patched.
  119. * @returns the restore function (the in-process tests un-patch; the real
  120. * worker never needs to).
  121. */
  122. export function captureStreamWrites(logs: LogBuffer, stream: PatchableStream): () => void {
  123. // The slot's VALUE is stored for restore and reassigned — never invoked
  124. // detached, so the unbound-method concern does not apply.
  125. // oxlint-disable-next-line typescript/unbound-method
  126. const original = stream.write
  127. stream.write = (chunk: unknown, ...rest: unknown[]): boolean => {
  128. logs.push(typeof chunk === 'string' ? chunk : String(chunk))
  129. // Node's optional-encoding shape: the callback is whichever of the next
  130. // two positions holds a function (a non-function there is the encoding).
  131. const callback = [rest[0], rest[1]].find(
  132. (arg): arg is (error?: Error | null) => void => typeof arg === 'function',
  133. )
  134. if (callback) queueMicrotask(() => { callback(null) })
  135. return true
  136. }
  137. return () => { stream.write = original }
  138. }
  139. /** Bounded inspect options: deep enough to be useful, bounded so a pathological value cannot explode the rendering. */
  140. const INSPECT_OPTIONS = { depth: 4, maxArrayLength: 100, maxStringLength: 10_000 } as const
  141. /**
  142. * Prepare the program's completion value for the done message. Only lossless
  143. * JSON crosses, and a value that does not fit the remaining combined outer
  144. * budget reports `output-limit`; the host revalidates hostile traffic and
  145. * remains authoritative for native pipe writes the worker cannot observe.
  146. *
  147. * @param value - the program's completion value.
  148. * @param remainingOutputBytes - exact bytes left after captured logs.
  149. * @param maxOutputBytes - the configured cap named in an overflow diagnostic.
  150. * @returns the done-message fragment: `{}` for `undefined`, else a flat wire `{ value }`.
  151. */
  152. export function prepareCompletion(
  153. value: unknown,
  154. remainingOutputBytes: number,
  155. maxOutputBytes: number = remainingOutputBytes,
  156. ): Omit<DoneMessage, 'type'> {
  157. if (value === undefined) return {}
  158. let snapshot: ReturnType<typeof snapshotCodeJsonValue>
  159. try {
  160. snapshot = snapshotCodeJsonValue(value)
  161. } catch {
  162. snapshot = undefined
  163. }
  164. if (snapshot === undefined) {
  165. return prepareFailure(
  166. 'invalid-output',
  167. 'program completion must be lossless JSON',
  168. remainingOutputBytes,
  169. maxOutputBytes,
  170. )
  171. }
  172. if (jsonValueBytesUpTo(snapshot, remainingOutputBytes) === undefined) {
  173. return outputLimit(maxOutputBytes)
  174. }
  175. return { value: encodeWorkerJson(snapshot) }
  176. }
  177. /** Build the fixed overflow fragment without carrying rejected variable bytes. */
  178. function outputLimit(maxOutputBytes: number): Omit<DoneMessage, 'type'> {
  179. return { error: { kind: 'output-limit', message: `outer output exceeded ${maxOutputBytes} bytes` } }
  180. }
  181. /** Admit one bounded failure message or replace it with the fixed overflow diagnostic. */
  182. function prepareFailure(
  183. kind: 'exception' | 'invalid-output',
  184. message: string,
  185. remainingOutputBytes: number,
  186. maxOutputBytes: number,
  187. ): Omit<DoneMessage, 'type'> {
  188. if (jsonStringBytesUpTo(message, remainingOutputBytes) === undefined) return outputLimit(maxOutputBytes)
  189. return { error: { kind, message } }
  190. }
  191. /**
  192. * Prepare a thrown program value without sending an unbounded stack or
  193. * string across the worker port.
  194. * @param error - the value thrown by the program.
  195. * @param remainingOutputBytes - exact bytes left after captured logs.
  196. * @param maxOutputBytes - the configured cap named in an overflow diagnostic.
  197. * @returns a bounded exception or fixed output-limit fragment.
  198. */
  199. export function prepareException(
  200. error: unknown,
  201. remainingOutputBytes: number,
  202. maxOutputBytes: number = remainingOutputBytes,
  203. ): Omit<DoneMessage, 'type'> {
  204. let message: string
  205. try {
  206. const detail: unknown = error instanceof CapturedError ? error.stack ?? error.message : error
  207. message = typeof detail === 'string' ? detail : String(detail)
  208. } catch {
  209. message = 'program threw an unrenderable value'
  210. }
  211. return prepareFailure('exception', message, remainingOutputBytes, maxOutputBytes)
  212. }
  213. /** One awaited binding call's settlement handles, keyed by call id in the pending map. */
  214. export interface PendingCall {
  215. resolve(value: unknown): void
  216. reject(error: Error): void
  217. }
  218. /** Constructor type for one program-visible binding rejection class. */
  219. export type BindingErrorConstructor = new (memberName: string, message: string) => Error
  220. /**
  221. * Materialize the real error constructor declared by one namespace.
  222. * @param descriptor - program-global class name and member-name property.
  223. * @returns the constructor injected into the program and used for rejections.
  224. */
  225. function makeBindingErrorClass(
  226. descriptor: { name: string; memberNameProperty: string },
  227. ): BindingErrorConstructor {
  228. return class BindingCallError extends CapturedError {
  229. constructor(memberName: string, message: string) {
  230. super(message)
  231. defineBindingErrorField(this, 'name', descriptor.name)
  232. defineBindingErrorField(this, descriptor.memberNameProperty, memberName)
  233. }
  234. }
  235. }
  236. /** Create the namespace-specific rejection for one failed binding call. */
  237. function bindingFailure(errorClass: BindingErrorConstructor | undefined, memberName: string, message: string): Error {
  238. return errorClass ? new errorClass(memberName, message) : new CapturedError(message)
  239. }
  240. /**
  241. * Build each declared error class once so calls and `instanceof` share constructor identity.
  242. * @param data - binding namespace declarations from the boot payload.
  243. * @returns constructors keyed by their owning namespace global.
  244. */
  245. export function makeBindingErrorClasses(
  246. data: Pick<WorkerBootData, 'namespaces'>,
  247. ): Map<string, BindingErrorConstructor> {
  248. const classes = new Map<string, BindingErrorConstructor>()
  249. for (const namespace of data.namespaces) {
  250. if (namespace.errorClass) classes.set(namespace.global, makeBindingErrorClass(namespace.errorClass))
  251. }
  252. return classes
  253. }
  254. /**
  255. * Route host replies into the pending-call map: each reply settles its call
  256. * at most once, and a reply for an unknown id (stray, or a duplicate answer
  257. * to an id already settled) is ignored. Shared wiring between
  258. * {@link runWorkerMain} and the tests that exercise {@link makeNamespaces}
  259. * standalone.
  260. * @param port - the port whose `message` events carry the replies.
  261. * @param pending - the id-keyed map of unsettled binding calls.
  262. */
  263. export function wireReplies(port: BootstrapPort, pending: Map<number, PendingCall>): void {
  264. port.on('message', (message: ReplyMessage) => {
  265. const entry = pending.get(message.id)
  266. if (!entry) return
  267. pending.delete(message.id)
  268. if (message.ok) {
  269. const value = decodeWorkerJson(message.value)
  270. if (value === undefined) entry.reject(new CapturedError('binding resolution must be lossless JSON'))
  271. else entry.resolve(value)
  272. } else {
  273. entry.reject(new CapturedError(message.message))
  274. }
  275. })
  276. }
  277. /**
  278. * Build the binding namespace objects the program sees: one null-prototype global per
  279. * namespace, each declared name an own enumerable async function that bridges over the port
  280. * (`__proto__`/`constructor`/`toString` are ordinary keys, never prototype collisions).
  281. * Lossy arguments reject before posting; clone failures and host failure
  282. * replies reject only the corresponding call.
  283. *
  284. * @param data - the boot payload's namespace declarations (globals + names).
  285. * @param port - the port binding calls are posted to.
  286. * @param pending - the id-keyed map each posted call parks its handles in.
  287. * @param nextId - the shared mutable id counter (worker-issued correlation ids).
  288. * @param errorClasses - per-namespace constructors shared with program globals.
  289. * @returns one namespace object per declaration, in declaration order.
  290. */
  291. export function makeNamespaces(
  292. data: Pick<WorkerBootData, 'namespaces'>,
  293. port: BootstrapPort,
  294. pending: Map<number, PendingCall>,
  295. nextId: { value: number },
  296. errorClasses: Map<string, BindingErrorConstructor> = makeBindingErrorClasses(data),
  297. ): Record<string, unknown>[] {
  298. return data.namespaces.map(({ global, names }) => {
  299. const errorClass = errorClasses.get(global)
  300. const namespace = Object.create(null) as Record<string, unknown>
  301. for (const name of names) {
  302. Object.defineProperty(namespace, name, {
  303. enumerable: true,
  304. value: (args: unknown): Promise<unknown> => {
  305. let detached: ReturnType<typeof snapshotCodeJsonValue>
  306. try {
  307. detached = snapshotCodeJsonValue(args)
  308. } catch {
  309. detached = undefined
  310. }
  311. if (detached === undefined) {
  312. return Promise.reject(bindingFailure(errorClass, name, 'binding arguments must be lossless JSON'))
  313. }
  314. return new Promise((resolve, reject) => {
  315. const id = nextId.value++
  316. pending.set(id, {
  317. resolve,
  318. reject: (error) => {
  319. reject(bindingFailure(errorClass, name, error.message))
  320. },
  321. })
  322. try {
  323. port.postMessage({ type: 'call', id, global, name, args: encodeWorkerJson(detached) })
  324. } catch (error: unknown) {
  325. pending.delete(id)
  326. const message = `binding arguments must be structured-cloneable: ${error instanceof CapturedError ? error.message : String(error)}`
  327. reject(bindingFailure(errorClass, name, message))
  328. }
  329. })
  330. },
  331. })
  332. }
  333. return namespace
  334. })
  335. }
  336. /**
  337. * Run one strict async-function body, allowing top-level `await` and `return`, and post exactly
  338. * one terminal {@link DoneMessage}; a thrown program error becomes its `error` field.
  339. * @param port - host message port or test double.
  340. * @param data - the boot payload the host sent.
  341. * @param streams - stdout/stderr objects captured as program logs.
  342. * @returns after posting the done message.
  343. */
  344. export async function runWorkerMain(
  345. port: BootstrapPort,
  346. data: WorkerBootData,
  347. streams: { stdout: PatchableStream; stderr: PatchableStream },
  348. ): Promise<void> {
  349. const logs = new LogBuffer(
  350. data.maxOutputBytes,
  351. (text) => { port.postMessage({ type: 'log', text }) },
  352. () => { port.postMessage({ type: 'output-limit' }) },
  353. )
  354. captureStreamWrites(logs, streams.stdout)
  355. captureStreamWrites(logs, streams.stderr)
  356. const pending = new Map<number, PendingCall>()
  357. wireReplies(port, pending)
  358. const nextId = { value: 1 }
  359. const errorClasses = makeBindingErrorClasses(data)
  360. const namespaces = makeNamespaces(data, port, pending, nextId, errorClasses)
  361. const errorClassParameters: string[] = []
  362. const errorClassValues: BindingErrorConstructor[] = []
  363. for (const namespace of data.namespaces) {
  364. if (!namespace.errorClass) continue
  365. errorClassParameters.push(namespace.errorClass.name)
  366. const errorClass = errorClasses.get(namespace.global)
  367. /* v8 ignore next -- makeBindingErrorClasses covers every declaration in the same data. */
  368. if (!errorClass) throw new CapturedError(`missing binding error class for ${namespace.global}`)
  369. errorClassValues.push(errorClass)
  370. }
  371. const consoleShim = makeConsoleShim(logs)
  372. let done: DoneMessage
  373. try {
  374. // The async function constructor, reached through an instance because
  375. // `AsyncFunction` is not a global. The program body is strict-mode.
  376. /* v8 ignore next -- the arrow exists only to reach the AsyncFunction constructor; it is never invoked. */
  377. const AsyncFunction = (async () => {}).constructor as new (...args: string[]) => (...fnArgs: unknown[]) => Promise<unknown>
  378. const fn = new AsyncFunction(
  379. ...data.namespaces.map(namespace => namespace.global),
  380. ...errorClassParameters,
  381. 'console',
  382. `'use strict';\n${data.code}`,
  383. )
  384. const value = await fn(...namespaces, ...errorClassValues, consoleShim)
  385. done = {
  386. type: 'done',
  387. ...prepareCompletion(value, logs.remainingOutputBytes(), data.maxOutputBytes),
  388. }
  389. } catch (error: unknown) {
  390. done = {
  391. type: 'done',
  392. ...prepareException(error, logs.remainingOutputBytes(), data.maxOutputBytes),
  393. }
  394. }
  395. port.postMessage(done)
  396. }