2026-07-08 11:00:06 +08:00
|
|
|
|
/**
|
2026-07-12 03:36:43 +08:00
|
|
|
|
* Worker-side execution logic, written as plain functions over an injected port so the unit
|
|
|
|
|
|
* suite can run every line IN-PROCESS against a fake port (a real worker thread is a separate
|
|
|
|
|
|
* V8 isolate the coverage provider cannot observe).
|
2026-07-08 11:00:06 +08:00
|
|
|
|
* @module @deepseek-ai/dsh-code-runtime-worker/src/bootstrap
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import { inspect } from 'node:util'
|
2026-07-21 04:34:14 +08:00
|
|
|
|
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
|
2026-07-08 11:00:06 +08:00
|
|
|
|
import type { DoneMessage, ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
|
|
|
|
|
|
|
|
|
|
|
|
/** The port surface the bootstrap needs — satisfied by `parentPort` and by the tests' fake. */
|
|
|
|
|
|
export interface BootstrapPort {
|
|
|
|
|
|
postMessage(message: WorkerToHost): void
|
|
|
|
|
|
on(event: 'message', listener: (message: ReplyMessage) => void): void
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* A writable stream's `write` slot, as the bootstrap patches it (see
|
|
|
|
|
|
* {@link captureStreamWrites}). Method-typed so the real
|
|
|
|
|
|
* `process.stdout`/`process.stderr` (narrower chunk parameters) remain
|
|
|
|
|
|
* assignable.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export interface PatchableStream {
|
|
|
|
|
|
write(chunk: unknown, ...rest: unknown[]): boolean
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-07-14 03:07:41 +08:00
|
|
|
|
* Ordered text capture under one shared byte budget, delivered to a sink as
|
|
|
|
|
|
* each item lands (the real sink streams text over the port eagerly, so
|
2026-07-08 11:00:06 +08:00
|
|
|
|
* captured output survives a mid-run termination). Once the budget is
|
2026-07-21 04:34:14 +08:00
|
|
|
|
* exhausted it emits the fitting prefix and reports the limit once; the host
|
|
|
|
|
|
* turns that condition into an explicit `output-limit` run failure.
|
2026-07-08 11:00:06 +08:00
|
|
|
|
*/
|
|
|
|
|
|
export class LogBuffer {
|
|
|
|
|
|
private remaining: number
|
|
|
|
|
|
private truncated = false
|
|
|
|
|
|
// Explicit fields, not constructor parameter properties: this module loads
|
|
|
|
|
|
// under Node's native strip-only mode, which rejects non-erasable syntax —
|
|
|
|
|
|
// and parameter properties are non-erasable.
|
2026-07-14 03:07:41 +08:00
|
|
|
|
private readonly sink: (text: string) => void
|
2026-07-21 04:34:14 +08:00
|
|
|
|
private readonly onLimit: () => void
|
2026-07-08 11:00:06 +08:00
|
|
|
|
|
2026-07-21 04:34:14 +08:00
|
|
|
|
constructor(maxBytes: number, sink: (text: string) => void, onLimit: () => void = () => {}) {
|
2026-07-08 11:00:06 +08:00
|
|
|
|
this.sink = sink
|
2026-07-21 04:34:14 +08:00
|
|
|
|
this.onLimit = onLimit
|
2026-07-08 11:00:06 +08:00
|
|
|
|
this.remaining = maxBytes
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-07-14 03:07:41 +08:00
|
|
|
|
* Emit text to the sink, charging it against the budget (drops + marks once exhausted).
|
|
|
|
|
|
* @param text - the captured text to deliver.
|
2026-07-08 11:00:06 +08:00
|
|
|
|
*/
|
2026-07-14 03:07:41 +08:00
|
|
|
|
push(text: string): void {
|
2026-07-08 11:00:06 +08:00
|
|
|
|
if (this.truncated) return
|
2026-07-14 03:07:41 +08:00
|
|
|
|
const cost = Buffer.byteLength(text, 'utf8')
|
2026-07-08 11:00:06 +08:00
|
|
|
|
if (cost > this.remaining) {
|
|
|
|
|
|
this.truncated = true
|
2026-07-21 04:34:14 +08:00
|
|
|
|
const prefix = truncateUtf8Bytes(text, this.remaining)
|
|
|
|
|
|
if (prefix.length > 0) this.sink(prefix)
|
|
|
|
|
|
this.remaining = 0
|
|
|
|
|
|
this.onLimit()
|
2026-07-08 11:00:06 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
this.remaining -= cost
|
2026-07-14 03:07:41 +08:00
|
|
|
|
this.sink(text)
|
2026-07-08 11:00:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** The five console methods the shim captures, in the seam's level vocabulary. */
|
|
|
|
|
|
const CONSOLE_LEVELS = ['log', 'info', 'warn', 'error', 'debug'] as const
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* A `console` replacement whose five leveled methods render their arguments
|
|
|
|
|
|
* `util.inspect`-style (matching real console formatting closely enough for
|
|
|
|
|
|
* a model to recognize its own output) into the buffer. Only these five
|
|
|
|
|
|
* exist — the program gets a deliberately small console, not Node's full
|
|
|
|
|
|
* surface.
|
|
|
|
|
|
* @param logs - the buffer every rendered line is pushed into.
|
|
|
|
|
|
* @returns the five-method console object handed to the program.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function makeConsoleShim(logs: LogBuffer): Record<(typeof CONSOLE_LEVELS)[number], (...args: unknown[]) => void> {
|
|
|
|
|
|
const render = (args: unknown[]): string =>
|
|
|
|
|
|
args.map(arg => typeof arg === 'string' ? arg : inspect(arg, INSPECT_OPTIONS)).join(' ')
|
|
|
|
|
|
const shim = Object.create(null) as Record<(typeof CONSOLE_LEVELS)[number], (...args: unknown[]) => void>
|
|
|
|
|
|
for (const level of CONSOLE_LEVELS) {
|
2026-07-14 03:07:41 +08:00
|
|
|
|
shim[level] = (...args: unknown[]) => { logs.push(render(args)) }
|
2026-07-08 11:00:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
return shim
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Redirect a stream's `write` into the log buffer (the program-visible
|
2026-07-12 03:36:43 +08:00
|
|
|
|
* `process.stdout`/`process.stderr` in the real worker), so raw writes land in emission order
|
2026-07-13 23:27:00 +08:00
|
|
|
|
* alongside console output instead of racing down a pipe. It preserves Node's optional callback
|
|
|
|
|
|
* contract: the callback runs asynchronously after admission, even when the log budget drops
|
|
|
|
|
|
* the write.
|
2026-07-12 03:36:43 +08:00
|
|
|
|
*
|
2026-07-08 11:00:06 +08:00
|
|
|
|
* @param logs - the buffer captured writes are pushed into.
|
|
|
|
|
|
* @param stream - the stream whose `write` slot is patched.
|
|
|
|
|
|
* @returns the restore function (the in-process tests un-patch; the real
|
|
|
|
|
|
* worker never needs to).
|
|
|
|
|
|
*/
|
2026-07-14 03:07:41 +08:00
|
|
|
|
export function captureStreamWrites(logs: LogBuffer, stream: PatchableStream): () => void {
|
2026-07-08 11:00:06 +08:00
|
|
|
|
// The slot's VALUE is stored for restore and reassigned — never invoked
|
|
|
|
|
|
// detached, so the unbound-method concern does not apply.
|
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/unbound-method
|
|
|
|
|
|
const original = stream.write
|
2026-07-08 21:56:28 +08:00
|
|
|
|
stream.write = (chunk: unknown, ...rest: unknown[]): boolean => {
|
2026-07-14 03:07:41 +08:00
|
|
|
|
logs.push(typeof chunk === 'string' ? chunk : String(chunk))
|
2026-07-08 21:56:28 +08:00
|
|
|
|
// Node's optional-encoding shape: the callback is whichever of the next
|
|
|
|
|
|
// two positions holds a function (a non-function there is the encoding).
|
|
|
|
|
|
const callback = [rest[0], rest[1]].find(
|
|
|
|
|
|
(arg): arg is (error?: Error | null) => void => typeof arg === 'function',
|
|
|
|
|
|
)
|
|
|
|
|
|
if (callback) queueMicrotask(() => { callback(null) })
|
2026-07-08 11:00:06 +08:00
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
return () => { stream.write = original }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Bounded inspect options: deep enough to be useful, bounded so a pathological value cannot explode the rendering. */
|
|
|
|
|
|
const INSPECT_OPTIONS = { depth: 4, maxArrayLength: 100, maxStringLength: 10_000 } as const
|
|
|
|
|
|
|
2026-07-08 21:56:28 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* The longest prefix of `text` whose UTF-8 encoding fits `maxBytes`, cut at
|
|
|
|
|
|
* a code-point boundary (never mid-surrogate-pair). The byte caps are BYTE
|
|
|
|
|
|
* caps — `String.prototype.slice` counts UTF-16 code units, up to 3× smaller
|
|
|
|
|
|
* than what a multibyte string actually costs across the boundary.
|
|
|
|
|
|
* @param text - the string to bound.
|
|
|
|
|
|
* @param maxBytes - the UTF-8 byte budget the prefix must fit.
|
|
|
|
|
|
* @returns the prefix (all of `text` when it already fits).
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function truncateUtf8Bytes(text: string, maxBytes: number): string {
|
|
|
|
|
|
if (Buffer.byteLength(text, 'utf8') <= maxBytes) return text
|
|
|
|
|
|
let bytes = 0
|
|
|
|
|
|
let end = 0
|
|
|
|
|
|
for (const char of text) {
|
|
|
|
|
|
const cost = Buffer.byteLength(char, 'utf8')
|
|
|
|
|
|
if (bytes + cost > maxBytes) break
|
|
|
|
|
|
bytes += cost
|
|
|
|
|
|
end += char.length
|
|
|
|
|
|
}
|
|
|
|
|
|
return text.slice(0, end)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-08 11:00:06 +08:00
|
|
|
|
/**
|
2026-07-21 04:34:14 +08:00
|
|
|
|
* Prepare the program's completion value for the done message. Only lossless
|
|
|
|
|
|
* JSON crosses, and an individually oversized value reports `output-limit`;
|
|
|
|
|
|
* the host revalidates both and accounts for the combined outer envelope.
|
2026-07-12 03:36:43 +08:00
|
|
|
|
*
|
2026-07-08 11:00:06 +08:00
|
|
|
|
* @param value - the program's completion value.
|
2026-07-21 04:34:14 +08:00
|
|
|
|
* @param maxOutputBytes - the byte cap for the outer result.
|
2026-07-08 11:00:06 +08:00
|
|
|
|
* @returns the done-message fragment: `{}` for `undefined`, else `{ value }`.
|
|
|
|
|
|
*/
|
2026-07-21 04:34:14 +08:00
|
|
|
|
export function prepareCompletion(value: unknown, maxOutputBytes: number): Omit<DoneMessage, 'type'> {
|
2026-07-08 11:00:06 +08:00
|
|
|
|
if (value === undefined) return {}
|
2026-07-21 04:34:14 +08:00
|
|
|
|
let snapshot: unknown
|
|
|
|
|
|
try {
|
|
|
|
|
|
snapshot = snapshotJsonValue(value)
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
snapshot = undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
if (snapshot === undefined) {
|
|
|
|
|
|
return { error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' } }
|
|
|
|
|
|
}
|
|
|
|
|
|
const size = Buffer.byteLength(JSON.stringify(snapshot), 'utf8')
|
|
|
|
|
|
if (size > maxOutputBytes) {
|
|
|
|
|
|
return { error: { kind: 'output-limit', message: `outer output exceeded ${maxOutputBytes} bytes` } }
|
2026-07-08 11:00:06 +08:00
|
|
|
|
}
|
2026-07-21 04:34:14 +08:00
|
|
|
|
return { value: snapshot }
|
2026-07-08 11:00:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** One awaited binding call's settlement handles, keyed by call id in the pending map. */
|
|
|
|
|
|
export interface PendingCall {
|
|
|
|
|
|
resolve(value: unknown): void
|
|
|
|
|
|
reject(error: Error): void
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 04:34:14 +08:00
|
|
|
|
/** Program-visible typed rejection for a failed member of the `tools` namespace. */
|
|
|
|
|
|
export class ToolCallError extends Error {
|
|
|
|
|
|
override readonly name = 'ToolCallError'
|
|
|
|
|
|
readonly toolName: string
|
|
|
|
|
|
|
|
|
|
|
|
constructor(toolName: string, message: string) {
|
|
|
|
|
|
super(message)
|
|
|
|
|
|
this.toolName = toolName
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-08 11:00:06 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* Route host replies into the pending-call map: each reply settles its call
|
|
|
|
|
|
* at most once, and a reply for an unknown id (stray, or a duplicate answer
|
|
|
|
|
|
* to an id already settled) is ignored. Shared wiring between
|
|
|
|
|
|
* {@link runWorkerMain} and the tests that exercise {@link makeNamespaces}
|
|
|
|
|
|
* standalone.
|
|
|
|
|
|
* @param port - the port whose `message` events carry the replies.
|
|
|
|
|
|
* @param pending - the id-keyed map of unsettled binding calls.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function wireReplies(port: BootstrapPort, pending: Map<number, PendingCall>): void {
|
|
|
|
|
|
port.on('message', (message: ReplyMessage) => {
|
|
|
|
|
|
const entry = pending.get(message.id)
|
|
|
|
|
|
if (!entry) return
|
|
|
|
|
|
pending.delete(message.id)
|
|
|
|
|
|
if (message.ok) entry.resolve(message.value)
|
|
|
|
|
|
else entry.reject(new Error(message.message))
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-07-12 03:36:43 +08:00
|
|
|
|
* Build the binding namespace objects the program sees: one null-prototype global per
|
|
|
|
|
|
* namespace, each declared name an own enumerable async function that bridges over the port
|
|
|
|
|
|
* (`__proto__`/`constructor`/`toString` are ordinary keys, never prototype collisions).
|
2026-07-13 23:27:00 +08:00
|
|
|
|
* Non-cloneable arguments and host failure replies reject only the corresponding call.
|
2026-07-12 03:36:43 +08:00
|
|
|
|
*
|
2026-07-08 11:00:06 +08:00
|
|
|
|
* @param data - the boot payload's namespace declarations (globals + names).
|
|
|
|
|
|
* @param port - the port binding calls are posted to.
|
|
|
|
|
|
* @param pending - the id-keyed map each posted call parks its handles in.
|
|
|
|
|
|
* @param nextId - the shared mutable id counter (worker-issued correlation ids).
|
|
|
|
|
|
* @returns one namespace object per declaration, in declaration order.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function makeNamespaces(
|
|
|
|
|
|
data: Pick<WorkerBootData, 'namespaces'>,
|
|
|
|
|
|
port: BootstrapPort,
|
|
|
|
|
|
pending: Map<number, PendingCall>,
|
|
|
|
|
|
nextId: { value: number },
|
|
|
|
|
|
): Record<string, unknown>[] {
|
|
|
|
|
|
return data.namespaces.map(({ global, names }) => {
|
|
|
|
|
|
const namespace = Object.create(null) as Record<string, unknown>
|
|
|
|
|
|
for (const name of names) {
|
|
|
|
|
|
Object.defineProperty(namespace, name, {
|
|
|
|
|
|
enumerable: true,
|
|
|
|
|
|
value: (args: unknown): Promise<unknown> => new Promise((resolve, reject) => {
|
|
|
|
|
|
const id = nextId.value++
|
2026-07-21 04:34:14 +08:00
|
|
|
|
pending.set(id, {
|
|
|
|
|
|
resolve,
|
|
|
|
|
|
reject: (error) => {
|
|
|
|
|
|
reject(global === 'tools' ? new ToolCallError(name, error.message) : error)
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
2026-07-08 11:00:06 +08:00
|
|
|
|
try {
|
|
|
|
|
|
port.postMessage({ type: 'call', id, global, name, args })
|
|
|
|
|
|
} catch (error: unknown) {
|
|
|
|
|
|
pending.delete(id)
|
2026-07-21 04:34:14 +08:00
|
|
|
|
const message = `binding arguments must be structured-cloneable: ${error instanceof Error ? error.message : String(error)}`
|
|
|
|
|
|
reject(global === 'tools' ? new ToolCallError(name, message) : new Error(message))
|
2026-07-08 11:00:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
}),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
return namespace
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-07-13 23:27:00 +08:00
|
|
|
|
* Run one strict async-function body, allowing top-level `await` and `return`, and post exactly
|
|
|
|
|
|
* one terminal {@link DoneMessage}; a thrown program error becomes its `error` field.
|
2026-07-12 03:36:43 +08:00
|
|
|
|
* @param port - host message port or test double.
|
2026-07-08 11:00:06 +08:00
|
|
|
|
* @param data - the boot payload the host sent.
|
2026-07-12 03:36:43 +08:00
|
|
|
|
* @param streams - stdout/stderr objects captured as program logs.
|
|
|
|
|
|
* @returns after posting the done message.
|
2026-07-08 11:00:06 +08:00
|
|
|
|
*/
|
|
|
|
|
|
export async function runWorkerMain(
|
|
|
|
|
|
port: BootstrapPort,
|
|
|
|
|
|
data: WorkerBootData,
|
|
|
|
|
|
streams: { stdout: PatchableStream; stderr: PatchableStream },
|
|
|
|
|
|
): Promise<void> {
|
2026-07-21 04:34:14 +08:00
|
|
|
|
const logs = new LogBuffer(
|
|
|
|
|
|
data.maxOutputBytes,
|
|
|
|
|
|
(text) => { port.postMessage({ type: 'log', text }) },
|
|
|
|
|
|
() => { port.postMessage({ type: 'output-limit' }) },
|
|
|
|
|
|
)
|
2026-07-14 03:07:41 +08:00
|
|
|
|
captureStreamWrites(logs, streams.stdout)
|
|
|
|
|
|
captureStreamWrites(logs, streams.stderr)
|
2026-07-08 11:00:06 +08:00
|
|
|
|
|
|
|
|
|
|
const pending = new Map<number, PendingCall>()
|
|
|
|
|
|
wireReplies(port, pending)
|
|
|
|
|
|
|
|
|
|
|
|
const nextId = { value: 1 }
|
|
|
|
|
|
const namespaces = makeNamespaces(data, port, pending, nextId)
|
|
|
|
|
|
const consoleShim = makeConsoleShim(logs)
|
|
|
|
|
|
|
|
|
|
|
|
let done: DoneMessage
|
|
|
|
|
|
try {
|
|
|
|
|
|
// The async function constructor, reached through an instance because
|
|
|
|
|
|
// `AsyncFunction` is not a global. The program body is strict-mode.
|
|
|
|
|
|
/* v8 ignore next -- the arrow exists only to reach the AsyncFunction constructor; it is never invoked. */
|
|
|
|
|
|
const AsyncFunction = (async () => {}).constructor as new (...args: string[]) => (...fnArgs: unknown[]) => Promise<unknown>
|
2026-07-21 04:34:14 +08:00
|
|
|
|
const fn = new AsyncFunction(...data.namespaces.map(namespace => namespace.global), 'ToolCallError', 'console', `'use strict';\n${data.code}`)
|
|
|
|
|
|
const value = await fn(...namespaces, ToolCallError, consoleShim)
|
|
|
|
|
|
done = { type: 'done', ...prepareCompletion(value, data.maxOutputBytes) }
|
2026-07-08 11:00:06 +08:00
|
|
|
|
} catch (error: unknown) {
|
|
|
|
|
|
const message = error instanceof Error ? error.stack ?? error.message : String(error)
|
2026-07-21 04:34:14 +08:00
|
|
|
|
done = { type: 'done', error: { kind: 'exception', message } }
|
2026-07-08 11:00:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
port.postMessage(done)
|
|
|
|
|
|
}
|