fix(code-mode): generalize failures and bound diagnostics

This commit is contained in:
Tianyi Cui
2026-07-23 01:32:17 +08:00
parent 8c1a9b7752
commit fb74156cf8
20 changed files with 483 additions and 129 deletions
@@ -7,7 +7,7 @@
import { inspect } from 'node:util'
import type { DoneMessage, ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
import { jsonValueBytesUpTo } from './output-json.ts'
import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from './output-json.ts'
import { decodeWorkerJson, encodeWorkerJson, snapshotCodeJsonValue } from './worker-json.ts'
/** The port surface the bootstrap needs — satisfied by `parentPort` and by the tests' fake. */
@@ -27,25 +27,28 @@ export interface PatchableStream {
}
/**
* 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
* captured output survives a mid-run termination). Once the budget is
* exhausted it emits the fitting prefix and reports the limit once; the host
* turns that condition into an explicit `output-limit` run failure.
* Ordered text capture under the shared outer JSON-byte budget, delivered to
* a sink as each item lands (the real sink streams text over the port eagerly,
* so captured output survives a mid-run termination). It includes the log
* array syntax and string escaping in its accounting. Once exhausted it emits
* the fitting prefix and reports the limit once; the host turns that condition
* into an explicit `output-limit` run failure.
*/
export class LogBuffer {
private remaining: number
private bytes = 2 // JSON serialization of the empty logs array: []
private entries = 0
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.
private readonly sink: (text: string) => void
private readonly onLimit: () => void
private readonly maxBytes: number
constructor(maxBytes: number, sink: (text: string) => void, onLimit: () => void = () => {}) {
this.maxBytes = maxBytes
this.sink = sink
this.onLimit = onLimit
this.remaining = maxBytes
}
/**
@@ -54,18 +57,32 @@ export class LogBuffer {
*/
push(text: string): void {
if (this.truncated) return
const cost = Buffer.byteLength(text, 'utf8')
if (cost > this.remaining) {
const separatorBytes = this.entries > 0 ? 1 : 0
const availableBytes = this.maxBytes - this.bytes - separatorBytes
const stringBytes = jsonStringBytesUpTo(text, availableBytes)
if (stringBytes === undefined) {
this.truncated = true
const prefix = truncateUtf8Bytes(text, this.remaining)
if (prefix.length > 0) this.sink(prefix)
this.remaining = 0
const prefix = truncateJsonStringBytes(text, availableBytes)
if (prefix.length > 0) {
const prefixBytes = jsonStringBytesUpTo(prefix, availableBytes)
/* v8 ignore next -- truncateJsonStringBytes guarantees the returned prefix fits. */
if (prefixBytes === undefined) throw new Error('worker output ledger produced an oversized log prefix')
this.bytes += prefixBytes + separatorBytes
this.entries += 1
this.sink(prefix)
}
this.onLimit()
return
}
this.remaining -= cost
this.bytes += stringBytes + separatorBytes
this.entries += 1
this.sink(text)
}
/** Remaining exact JSON-byte budget for the completion value or failure message. */
remainingOutputBytes(): number {
return this.maxBytes - this.bytes
}
}
/** The five console methods the shim captures, in the seam's level vocabulary. */
@@ -123,38 +140,22 @@ export function captureStreamWrites(logs: LogBuffer, stream: PatchableStream): (
/** 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
/**
* 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)
}
/**
* 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.
* JSON crosses, and a value that does not fit the remaining combined outer
* budget reports `output-limit`; the host revalidates hostile traffic and
* remains authoritative for native pipe writes the worker cannot observe.
*
* @param value - the program's completion value.
* @param maxOutputBytes - the byte cap for the outer result.
* @param remainingOutputBytes - exact bytes left after captured logs.
* @param maxOutputBytes - the configured cap named in an overflow diagnostic.
* @returns the done-message fragment: `{}` for `undefined`, else a flat wire `{ value }`.
*/
export function prepareCompletion(value: unknown, maxOutputBytes: number): Omit<DoneMessage, 'type'> {
export function prepareCompletion(
value: unknown,
remainingOutputBytes: number,
maxOutputBytes: number = remainingOutputBytes,
): Omit<DoneMessage, 'type'> {
if (value === undefined) return {}
let snapshot: ReturnType<typeof snapshotCodeJsonValue>
try {
@@ -163,35 +164,102 @@ export function prepareCompletion(value: unknown, maxOutputBytes: number): Omit<
snapshot = undefined
}
if (snapshot === undefined) {
return { error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' } }
return prepareFailure(
'invalid-output',
'program completion must be lossless JSON',
remainingOutputBytes,
maxOutputBytes,
)
}
if (jsonValueBytesUpTo(snapshot, maxOutputBytes) === undefined) {
return { error: { kind: 'output-limit', message: `outer output exceeded ${maxOutputBytes} bytes` } }
if (jsonValueBytesUpTo(snapshot, remainingOutputBytes) === undefined) {
return outputLimit(maxOutputBytes)
}
return { value: encodeWorkerJson(snapshot) }
}
/** Build the fixed overflow fragment without carrying rejected variable bytes. */
function outputLimit(maxOutputBytes: number): Omit<DoneMessage, 'type'> {
return { error: { kind: 'output-limit', message: `outer output exceeded ${maxOutputBytes} bytes` } }
}
/** Admit one bounded failure message or replace it with the fixed overflow diagnostic. */
function prepareFailure(
kind: 'exception' | 'invalid-output',
message: string,
remainingOutputBytes: number,
maxOutputBytes: number,
): Omit<DoneMessage, 'type'> {
if (jsonStringBytesUpTo(message, remainingOutputBytes) === undefined) return outputLimit(maxOutputBytes)
return { error: { kind, message } }
}
/**
* Prepare a thrown program value without sending an unbounded stack or
* string across the worker port.
* @param error - the value thrown by the program.
* @param remainingOutputBytes - exact bytes left after captured logs.
* @param maxOutputBytes - the configured cap named in an overflow diagnostic.
* @returns a bounded exception or fixed output-limit fragment.
*/
export function prepareException(
error: unknown,
remainingOutputBytes: number,
maxOutputBytes: number = remainingOutputBytes,
): Omit<DoneMessage, 'type'> {
let message: string
try {
const detail: unknown = error instanceof Error ? error.stack ?? error.message : error
message = typeof detail === 'string' ? detail : String(detail)
} catch {
message = 'program threw an unrenderable value'
}
return prepareFailure('exception', message, remainingOutputBytes, maxOutputBytes)
}
/** 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
}
/** 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 shape for one program-visible binding rejection class. */
export type BindingErrorConstructor = new (memberName: string, message: string) => Error
constructor(toolName: string, message: string) {
super(message)
this.toolName = toolName
/**
* Materialize the real error constructor declared by one namespace.
* @param descriptor - program-global class name and member-name property.
* @returns the constructor injected into the program and used for rejections.
*/
export function makeBindingErrorClass(
descriptor: { name: string; memberNameProperty: string },
): BindingErrorConstructor {
return class BindingCallError extends Error {
constructor(memberName: string, message: string) {
super(message)
Object.defineProperty(this, 'name', { enumerable: true, value: descriptor.name })
Object.defineProperty(this, descriptor.memberNameProperty, { enumerable: true, value: memberName })
}
}
}
/** Create the namespace-specific rejection for one lossy binding argument. */
function bindingArgumentFailure(global: string, name: string): Error {
const message = 'binding arguments must be lossless JSON'
return global === 'tools' ? new ToolCallError(name, message) : new Error(message)
/** Create the namespace-specific rejection for one failed binding call. */
function bindingFailure(errorClass: BindingErrorConstructor | undefined, memberName: string, message: string): Error {
return errorClass ? new errorClass(memberName, message) : new Error(message)
}
/**
* Build each declared error class once so calls and `instanceof` share constructor identity.
* @param data - binding namespace declarations from the boot payload.
* @returns constructors keyed by their owning namespace global.
*/
export function makeBindingErrorClasses(
data: Pick<WorkerBootData, 'namespaces'>,
): Map<string, BindingErrorConstructor> {
const classes = new Map<string, BindingErrorConstructor>()
for (const namespace of data.namespaces) {
if (namespace.errorClass) classes.set(namespace.global, makeBindingErrorClass(namespace.errorClass))
}
return classes
}
/**
@@ -229,6 +297,7 @@ export function wireReplies(port: BootstrapPort, pending: Map<number, PendingCal
* @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).
* @param errorClasses - per-namespace constructors shared with program globals.
* @returns one namespace object per declaration, in declaration order.
*/
export function makeNamespaces(
@@ -236,8 +305,10 @@ export function makeNamespaces(
port: BootstrapPort,
pending: Map<number, PendingCall>,
nextId: { value: number },
errorClasses: Map<string, BindingErrorConstructor> = makeBindingErrorClasses(data),
): Record<string, unknown>[] {
return data.namespaces.map(({ global, names }) => {
const errorClass = errorClasses.get(global)
const namespace = Object.create(null) as Record<string, unknown>
for (const name of names) {
Object.defineProperty(namespace, name, {
@@ -249,13 +320,15 @@ export function makeNamespaces(
} catch {
detached = undefined
}
if (detached === undefined) return Promise.reject(bindingArgumentFailure(global, name))
if (detached === undefined) {
return Promise.reject(bindingFailure(errorClass, name, 'binding arguments must be lossless JSON'))
}
return new Promise((resolve, reject) => {
const id = nextId.value++
pending.set(id, {
resolve,
reject: (error) => {
reject(global === 'tools' ? new ToolCallError(name, error.message) : error)
reject(bindingFailure(errorClass, name, error.message))
},
})
try {
@@ -263,7 +336,7 @@ export function makeNamespaces(
} catch (error: unknown) {
pending.delete(id)
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))
reject(bindingFailure(errorClass, name, message))
}
})
},
@@ -298,7 +371,18 @@ export async function runWorkerMain(
wireReplies(port, pending)
const nextId = { value: 1 }
const namespaces = makeNamespaces(data, port, pending, nextId)
const errorClasses = makeBindingErrorClasses(data)
const namespaces = makeNamespaces(data, port, pending, nextId, errorClasses)
const errorClassParameters: string[] = []
const errorClassValues: BindingErrorConstructor[] = []
for (const namespace of data.namespaces) {
if (!namespace.errorClass) continue
errorClassParameters.push(namespace.errorClass.name)
const errorClass = errorClasses.get(namespace.global)
/* v8 ignore next -- makeBindingErrorClasses covers every declaration in the same data. */
if (!errorClass) throw new Error(`missing binding error class for ${namespace.global}`)
errorClassValues.push(errorClass)
}
const consoleShim = makeConsoleShim(logs)
let done: DoneMessage
@@ -307,12 +391,22 @@ export async function runWorkerMain(
// `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>
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) }
const fn = new AsyncFunction(
...data.namespaces.map(namespace => namespace.global),
...errorClassParameters,
'console',
`'use strict';\n${data.code}`,
)
const value = await fn(...namespaces, ...errorClassValues, consoleShim)
done = {
type: 'done',
...prepareCompletion(value, logs.remainingOutputBytes(), data.maxOutputBytes),
}
} catch (error: unknown) {
const message = error instanceof Error ? error.stack ?? error.message : String(error)
done = { type: 'done', error: { kind: 'exception', message } }
done = {
type: 'done',
...prepareException(error, logs.remainingOutputBytes(), data.maxOutputBytes),
}
}
port.postMessage(done)
}
@@ -13,7 +13,7 @@ import { fileURLToPath } from 'node:url'
import { Context } from 'cordis'
import z from 'schemastery'
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
import type { CodeBindingFunction, CodeJsonValue, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
import type { CodeBindingNamespace, CodeJsonValue, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from './output-json.ts'
@@ -74,6 +74,9 @@ const RESERVED_WORDS = new Set([
/** Valid async-function parameter name (the binding global becomes one). */
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
/** Error properties whose binding-member replacement would destroy the promised Error contract. */
const RESERVED_ERROR_PROPERTIES = new Set(['name', 'message', 'stack'])
/**
* The shell a program is wrapped in for the type-strip, matching the
* grammatical context it will execute in (an async function body, where
@@ -312,17 +315,33 @@ export class WorkerCodeRuntime extends CodeRuntime {
return new OutputLedger(this.config.maxOutputBytes).failure([], error)
}
/** Reject (seam misuse) malformed binding namespaces: non-identifier or reserved globals, duplicates, and the `console` collision. */
private validateBindings(request: CodeRunRequest): Map<string, Record<string, CodeBindingFunction>> {
const bindings = new Map<string, Record<string, CodeBindingFunction>>()
/** Reject malformed binding globals or typed-error declarations as seam misuse. */
private validateBindings(request: CodeRunRequest): Map<string, CodeBindingNamespace> {
const bindings = new Map<string, CodeBindingNamespace>()
for (const namespace of request.bindings) {
if (!IDENTIFIER.test(namespace.global) || RESERVED_WORDS.has(namespace.global)) {
throw new Error(`dsh-code-runtime-worker: binding global ${JSON.stringify(namespace.global)} is not a usable identifier`)
}
if (namespace.global === 'console' || namespace.global === 'ToolCallError' || bindings.has(namespace.global)) {
if (namespace.global === 'console' || bindings.has(namespace.global)) {
throw new Error(`dsh-code-runtime-worker: duplicate binding global ${JSON.stringify(namespace.global)}`)
}
bindings.set(namespace.global, namespace.functions)
bindings.set(namespace.global, namespace)
}
const errorClassNames = new Set<string>()
for (const namespace of request.bindings) {
const descriptor = namespace.errorClass
if (!descriptor) continue
if (!IDENTIFIER.test(descriptor.name) || RESERVED_WORDS.has(descriptor.name)) {
throw new Error(`dsh-code-runtime-worker: binding error class ${JSON.stringify(descriptor.name)} is not a usable identifier`)
}
if (descriptor.name === 'console' || bindings.has(descriptor.name) || errorClassNames.has(descriptor.name)) {
throw new Error(`dsh-code-runtime-worker: duplicate injected global ${JSON.stringify(descriptor.name)}`)
}
if (descriptor.memberNameProperty.length === 0 || RESERVED_ERROR_PROPERTIES.has(descriptor.memberNameProperty)) {
throw new Error(`dsh-code-runtime-worker: binding error member property ${JSON.stringify(descriptor.memberNameProperty)} is not usable`)
}
errorClassNames.add(descriptor.name)
}
return bindings
}
@@ -331,11 +350,15 @@ export class WorkerCodeRuntime extends CodeRuntime {
private execute(
request: CodeRunRequest,
code: string,
bindings: Map<string, Record<string, CodeBindingFunction>>,
bindings: Map<string, CodeBindingNamespace>,
): Promise<CodeRunResult> {
const bootData: WorkerBootData = {
code,
namespaces: [...bindings].map(([global, functions]) => ({ global, names: Object.keys(functions) })),
namespaces: [...bindings].map(([global, namespace]) => ({
global,
names: Object.keys(namespace.functions),
...namespace.errorClass ? { errorClass: namespace.errorClass } : {},
})),
maxOutputBytes: this.config.maxOutputBytes,
}
const worker = new Worker(WORKER_PATH, {
@@ -435,7 +458,7 @@ export class WorkerCodeRuntime extends CodeRuntime {
// this point, so this payload is structured-cloneable by contract.
worker.postMessage(payload)
}
const record = bindings.get(message.global)
const record = bindings.get(message.global)?.functions
// Own-property lookup only: a forged name like 'constructor' or
// 'hasOwnProperty' must not walk the record's prototype chain and
// reach a callable the consumer never declared.
@@ -11,8 +11,12 @@ import type { WorkerJsonWire } from './worker-json.ts'
export interface WorkerBootData {
/** The type-stripped (plain JS) program body. */
code: string
/** Binding namespaces to materialize: the global name plus the function names (functions themselves stay host-side). */
namespaces: { global: string; names: string[] }[]
/** Binding namespaces to materialize; functions themselves stay host-side. */
namespaces: {
global: string
names: string[]
errorClass?: { name: string; memberNameProperty: string }
}[]
/** Hard cap for the combined serialized outer logs plus completion value or failure diagnostic. */
maxOutputBytes: number
}
@@ -42,12 +46,12 @@ interface OutputLimitMessage {
}
/**
* Worker → host: the program settled. `error` carries a program exception
* (the only failure the bootstrap itself can report — budgets, aborts, and
* substrate death are observed host-side). `value` is present only on a
* clean completion that produced one, as a flat wire value already
* size-capped and lossless per the bootstrap. Logs are NOT carried here —
* they streamed eagerly as {@link LogMessage}s.
* Worker → host: the program settled. `error` carries a program exception,
* invalid completion, or output overflow (budgets, aborts, and substrate death
* are observed host-side). `value` is present only on a clean completion that
* produced one, as a flat wire value already lossless and admitted against
* the remaining combined output cap. Logs are NOT carried here — they streamed
* eagerly as {@link LogMessage}s.
*/
export interface DoneMessage {
type: 'done'