feat: return typed values from Code Mode
This commit is contained in:
@@ -6,8 +6,7 @@
|
||||
*/
|
||||
|
||||
import { inspect } from 'node:util'
|
||||
import { serialize } from 'node:v8'
|
||||
import { logTruncationMarker } from './protocol.ts'
|
||||
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { DoneMessage, ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
|
||||
|
||||
/** The port surface the bootstrap needs — satisfied by `parentPort` and by the tests' fake. */
|
||||
@@ -30,9 +29,8 @@ 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 exactly one in-band marker and silently drops everything
|
||||
* after. The cap is a blast-radius bound, so "how much was lost" intentionally
|
||||
* stays unmeasured.
|
||||
* 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
|
||||
@@ -40,12 +38,12 @@ export class LogBuffer {
|
||||
// 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 maxBytes: number
|
||||
private readonly sink: (text: string) => void
|
||||
private readonly onLimit: () => void
|
||||
|
||||
constructor(maxBytes: number, sink: (text: string) => void) {
|
||||
this.maxBytes = maxBytes
|
||||
constructor(maxBytes: number, sink: (text: string) => void, onLimit: () => void = () => {}) {
|
||||
this.sink = sink
|
||||
this.onLimit = onLimit
|
||||
this.remaining = maxBytes
|
||||
}
|
||||
|
||||
@@ -58,7 +56,10 @@ export class LogBuffer {
|
||||
const cost = Buffer.byteLength(text, 'utf8')
|
||||
if (cost > this.remaining) {
|
||||
this.truncated = true
|
||||
this.sink(logTruncationMarker(this.maxBytes))
|
||||
const prefix = truncateUtf8Bytes(text, this.remaining)
|
||||
if (prefix.length > 0) this.sink(prefix)
|
||||
this.remaining = 0
|
||||
this.onLimit()
|
||||
return
|
||||
}
|
||||
this.remaining -= cost
|
||||
@@ -144,37 +145,30 @@ export function truncateUtf8Bytes(text: string, maxBytes: number): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the program's completion value for the done message: a value whose MEASURED
|
||||
* cross-boundary size fits `maxValueBytes` crosses raw — exact bytes for a string, the
|
||||
* structured-clone wire size (`v8.serialize`) for everything else, so a huge container whose
|
||||
* bounded inspect rendering happens to be small cannot smuggle itself past the cap. Oversized
|
||||
* or non-cloneable values are replaced by a bounded string rendering with an in-band marker.
|
||||
* 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.
|
||||
*
|
||||
* @param value - the program's completion value.
|
||||
* @param maxValueBytes - the byte cap for the value.
|
||||
* @param maxOutputBytes - the byte cap for the outer result.
|
||||
* @returns the done-message fragment: `{}` for `undefined`, else `{ value }`.
|
||||
*/
|
||||
export function prepareValue(value: unknown, maxValueBytes: number): { value?: unknown } {
|
||||
export function prepareCompletion(value: unknown, maxOutputBytes: number): Omit<DoneMessage, 'type'> {
|
||||
if (value === undefined) return {}
|
||||
if (typeof value === 'string') {
|
||||
if (Buffer.byteLength(value, 'utf8') <= maxValueBytes) return { value }
|
||||
} else {
|
||||
let size: number | undefined
|
||||
try {
|
||||
size = serialize(value).byteLength
|
||||
} catch {
|
||||
// Only the verdict matters: the value has parts the structured-clone
|
||||
// algorithm rejects (functions, classes, …) and must cross as its
|
||||
// rendering instead.
|
||||
size = undefined
|
||||
}
|
||||
if (size !== undefined && size <= maxValueBytes) return { value }
|
||||
let snapshot: unknown
|
||||
try {
|
||||
snapshot = snapshotJsonValue(value)
|
||||
} catch {
|
||||
snapshot = undefined
|
||||
}
|
||||
const rendered = typeof value === 'string' ? value : inspect(value, INSPECT_OPTIONS)
|
||||
const capped = Buffer.byteLength(rendered, 'utf8') > maxValueBytes
|
||||
? `${truncateUtf8Bytes(rendered, maxValueBytes)}… [truncated]`
|
||||
: rendered
|
||||
return { value: capped }
|
||||
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` } }
|
||||
}
|
||||
return { value: snapshot }
|
||||
}
|
||||
|
||||
/** One awaited binding call's settlement handles, keyed by call id in the pending map. */
|
||||
@@ -183,6 +177,17 @@ export interface PendingCall {
|
||||
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(toolName: string, message: string) {
|
||||
super(message)
|
||||
this.toolName = toolName
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -227,12 +232,18 @@ export function makeNamespaces(
|
||||
enumerable: true,
|
||||
value: (args: unknown): Promise<unknown> => new Promise((resolve, reject) => {
|
||||
const id = nextId.value++
|
||||
pending.set(id, { resolve, reject })
|
||||
pending.set(id, {
|
||||
resolve,
|
||||
reject: (error) => {
|
||||
reject(global === 'tools' ? new ToolCallError(name, error.message) : error)
|
||||
},
|
||||
})
|
||||
try {
|
||||
port.postMessage({ type: 'call', id, global, name, args })
|
||||
} catch (error: unknown) {
|
||||
pending.delete(id)
|
||||
reject(new Error(`binding arguments must be structured-cloneable: ${error instanceof Error ? error.message : String(error)}`))
|
||||
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))
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -254,7 +265,11 @@ export async function runWorkerMain(
|
||||
data: WorkerBootData,
|
||||
streams: { stdout: PatchableStream; stderr: PatchableStream },
|
||||
): Promise<void> {
|
||||
const logs = new LogBuffer(data.maxLogBytes, (text) => { port.postMessage({ type: 'log', text }) })
|
||||
const logs = new LogBuffer(
|
||||
data.maxOutputBytes,
|
||||
(text) => { port.postMessage({ type: 'log', text }) },
|
||||
() => { port.postMessage({ type: 'output-limit' }) },
|
||||
)
|
||||
captureStreamWrites(logs, streams.stdout)
|
||||
captureStreamWrites(logs, streams.stderr)
|
||||
|
||||
@@ -271,12 +286,12 @@ 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), 'console', `'use strict';\n${data.code}`)
|
||||
const value = await fn(...namespaces, consoleShim)
|
||||
done = { type: 'done', ...prepareValue(value, data.maxValueBytes) }
|
||||
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) }
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.stack ?? error.message : String(error)
|
||||
done = { type: 'done', error: { message } }
|
||||
done = { type: 'done', error: { kind: 'exception', message } }
|
||||
}
|
||||
port.postMessage(done)
|
||||
}
|
||||
@@ -12,9 +12,8 @@ import { fileURLToPath } from 'node:url'
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
||||
import type { CodeBindingFunction, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
|
||||
import { prepareValue, truncateUtf8Bytes } from './bootstrap.ts'
|
||||
import { logTruncationMarker } from './protocol.ts'
|
||||
import type { CodeBindingFunction, 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'
|
||||
|
||||
/** Plugin config: every execution cap, changeable from `cordis.yml` (no hardcoded tunables). */
|
||||
@@ -35,14 +34,8 @@ export interface Config {
|
||||
* nobody will resolve).
|
||||
*/
|
||||
maxWallMs?: number
|
||||
/** Shared byte budget for captured log text (console + raw stream writes), truncation marked in-band. */
|
||||
maxLogBytes?: number
|
||||
/**
|
||||
* Byte cap for the completion value, measured by its real cross-boundary
|
||||
* size (string bytes, or structured-clone wire size); an oversized or
|
||||
* non-cloneable value crosses as a capped string rendering.
|
||||
*/
|
||||
maxValueBytes?: number
|
||||
/** Hard cap for the combined serialized outer logs, completion value, and failure diagnostic. */
|
||||
maxOutputBytes?: number
|
||||
/** The worker's max old-generation heap in MiB (`resourceLimits`); overflow kills the worker, surfacing as kind `'worker-exit'`. */
|
||||
maxOldGenerationSizeMb?: number
|
||||
}
|
||||
@@ -59,6 +52,9 @@ type ResolvedConfig = Required<Config>
|
||||
*/
|
||||
const ELU_POLL_INTERVAL_MS = 25
|
||||
|
||||
/** Smallest cap that can represent the empty logs array plus an empty JSON failure diagnostic. */
|
||||
const MIN_OUTPUT_BYTES = 4
|
||||
|
||||
/** ECMAScript reserved words that cannot be async-function parameter names — rejected as binding globals. */
|
||||
const RESERVED_WORDS = new Set([
|
||||
'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do',
|
||||
@@ -130,25 +126,74 @@ function parseWorkerMessage(raw: unknown): WorkerToHost | undefined {
|
||||
if (typeof m.text !== 'string') return undefined
|
||||
return { type: 'log', text: m.text }
|
||||
}
|
||||
case 'output-limit': return { type: 'output-limit' }
|
||||
case 'done': {
|
||||
if (m.error === undefined) return { type: 'done', ...m.value !== undefined ? { value: m.value } : {} }
|
||||
const error = m.error
|
||||
if (typeof error !== 'object' || error === null) return undefined
|
||||
const message = (error as Record<string, unknown>).message
|
||||
if (typeof message !== 'string') return undefined
|
||||
return { type: 'done', ...m.value !== undefined ? { value: m.value } : {}, error: { message } }
|
||||
const { kind, message } = error as Record<string, unknown>
|
||||
if ((kind !== 'exception' && kind !== 'invalid-output' && kind !== 'output-limit') || typeof message !== 'string') return undefined
|
||||
return { type: 'done', error: { kind, message } }
|
||||
}
|
||||
default: return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Headroom the host's value re-cap grants over `maxValueBytes`: exactly the
|
||||
* truncation suffix {@link prepareValue} appends, so a value the WORKER
|
||||
* already capped (byte-exact prefix + this marker) passes through unchanged
|
||||
* instead of being marked twice.
|
||||
*/
|
||||
const VALUE_RENDER_SLACK = Buffer.byteLength('… [truncated]', 'utf8')
|
||||
|
||||
/** Serialized byte size of one lossless JSON value. */
|
||||
function jsonBytes(value: CodeJsonValue): number {
|
||||
return Buffer.byteLength(JSON.stringify(value), 'utf8')
|
||||
}
|
||||
|
||||
/** One run's combined outer-output ledger; binding values never enter it. */
|
||||
class OutputLedger {
|
||||
private bytes = 2 // JSON serialization of the empty logs array: []
|
||||
private entries = 0
|
||||
|
||||
constructor(private readonly maxBytes: number) {}
|
||||
|
||||
/** Admit one exact log entry, or report that the hard cap was crossed. */
|
||||
admit(text: string, sink: string[]): boolean {
|
||||
const cost = Buffer.byteLength(JSON.stringify(text), 'utf8') + (this.entries > 0 ? 1 : 0)
|
||||
if (this.bytes + cost > this.maxBytes) return false
|
||||
this.bytes += cost
|
||||
this.entries += 1
|
||||
sink.push(text)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Finalize a successful absent-or-JSON completion against the combined cap. */
|
||||
success(logs: string[], value?: CodeJsonValue): CodeRunResult {
|
||||
if (value !== undefined && this.bytes + jsonBytes(value) > this.maxBytes) return this.limit(logs)
|
||||
return { logs, ...value !== undefined ? { value } : {} }
|
||||
}
|
||||
|
||||
/** Finalize a failure diagnostic, with output-limit taking precedence when combined bytes exceed the cap. */
|
||||
failure(logs: string[], error: CodeRunFailure): CodeRunResult {
|
||||
if (this.bytes + Buffer.byteLength(JSON.stringify(error.message), 'utf8') > this.maxBytes) return this.limit(logs)
|
||||
return { logs, error }
|
||||
}
|
||||
|
||||
/** Build the explicit output-limit failure while retaining the fitting log prefix. */
|
||||
limit(logs: string[]): CodeRunResult {
|
||||
const fullMessage = `outer output exceeded ${this.maxBytes} bytes`
|
||||
let retainedBytes = this.bytes
|
||||
const messageBytes = Buffer.byteLength(JSON.stringify(fullMessage), 'utf8')
|
||||
while (logs.length > 0 && retainedBytes + messageBytes > this.maxBytes) {
|
||||
const removed = logs.pop()
|
||||
/* v8 ignore next -- the while guard proves pop cannot return undefined. */
|
||||
if (removed === undefined) throw new Error('output ledger lost its final log entry')
|
||||
retainedBytes -= Buffer.byteLength(JSON.stringify(removed), 'utf8') + (logs.length > 0 ? 1 : 0)
|
||||
}
|
||||
const availableMessageBytes = this.maxBytes - retainedBytes
|
||||
// This fixed diagnostic is ASCII with no JSON escapes, so two bytes are
|
||||
// the surrounding quotes and every retained character costs one byte.
|
||||
const message = messageBytes <= availableMessageBytes
|
||||
? fullMessage
|
||||
: fullMessage.slice(0, availableMessageBytes - 2)
|
||||
return { logs, error: { kind: 'output-limit', message } }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The shipped {@link CodeRuntime} backend (`ctx.codeRuntime`). Registers as
|
||||
@@ -161,8 +206,7 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
||||
static Config: z<Config> = z.object({
|
||||
computeMs: z.number().default(60_000),
|
||||
maxWallMs: z.number().default(600_000),
|
||||
maxLogBytes: z.number().default(65_536),
|
||||
maxValueBytes: z.number().default(32_768),
|
||||
maxOutputBytes: z.number().default(67_108_864),
|
||||
maxOldGenerationSizeMb: z.number().default(512),
|
||||
})
|
||||
|
||||
@@ -181,6 +225,9 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
||||
for (const [key, value] of Object.entries(this.config)) {
|
||||
if (!(Number.isFinite(value) && value > 0)) throw new Error(`dsh-code-runtime-worker: config.${key} must be a positive number, got ${String(value)}`)
|
||||
}
|
||||
if (!Number.isSafeInteger(this.config.maxOutputBytes) || this.config.maxOutputBytes < MIN_OUTPUT_BYTES) {
|
||||
throw new Error(`dsh-code-runtime-worker: config.maxOutputBytes must be a safe integer of at least ${MIN_OUTPUT_BYTES}, got ${String(this.config.maxOutputBytes)}`)
|
||||
}
|
||||
ctx.effect(() => () => this.teardown(), 'worker code-runtime teardown')
|
||||
}
|
||||
|
||||
@@ -232,7 +279,7 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
||||
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' || bindings.has(namespace.global)) {
|
||||
if (namespace.global === 'console' || namespace.global === 'ToolCallError' || bindings.has(namespace.global)) {
|
||||
throw new Error(`dsh-code-runtime-worker: duplicate binding global ${JSON.stringify(namespace.global)}`)
|
||||
}
|
||||
bindings.set(namespace.global, namespace.functions)
|
||||
@@ -249,8 +296,7 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
||||
const bootData: WorkerBootData = {
|
||||
code,
|
||||
namespaces: [...bindings].map(([global, functions]) => ({ global, names: Object.keys(functions) })),
|
||||
maxLogBytes: this.config.maxLogBytes,
|
||||
maxValueBytes: this.config.maxValueBytes,
|
||||
maxOutputBytes: this.config.maxOutputBytes,
|
||||
}
|
||||
const worker = new Worker(WORKER_PATH, {
|
||||
workerData: bootData,
|
||||
@@ -274,28 +320,13 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
||||
const answered = new Set<number>()
|
||||
const logs: string[] = []
|
||||
const strayLogs: string[] = []
|
||||
|
||||
// One host-side budget covers normal, forged, and stray-pipe log entries. The first
|
||||
// overflow emits the shared in-band marker and drops everything after it.
|
||||
let logBudget = this.config.maxLogBytes
|
||||
let logsTruncated = false
|
||||
const admit = (text: string, sink: string[]): void => {
|
||||
if (logsTruncated) return
|
||||
const cost = Buffer.byteLength(text, 'utf8')
|
||||
if (cost > logBudget) {
|
||||
logsTruncated = true
|
||||
sink.push(logTruncationMarker(this.config.maxLogBytes))
|
||||
return
|
||||
}
|
||||
logBudget -= cost
|
||||
sink.push(text)
|
||||
}
|
||||
const output = new OutputLedger(this.config.maxOutputBytes)
|
||||
|
||||
// No settled guard: `finish` snapshots the arrays when it resolves, so
|
||||
// a chunk flushing after settlement mutates only the discarded buffers,
|
||||
// and the ledger bounds that growth until the pipes close.
|
||||
const captureStray = (chunk: Buffer): void => {
|
||||
admit(chunk.toString('utf8'), strayLogs)
|
||||
if (!settled && !output.admit(chunk.toString('utf8'), strayLogs)) finish(output.limit([...logs, ...strayLogs]))
|
||||
}
|
||||
worker.stdout.on('data', captureStray)
|
||||
worker.stderr.on('data', captureStray)
|
||||
@@ -304,7 +335,7 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
||||
// logs captured before timeout, abort, or failure remain in the result.
|
||||
let finishResolve!: () => void
|
||||
const finished = new Promise<void>((done) => { finishResolve = done })
|
||||
const finish = (result: Omit<CodeRunResult, 'logs'>): void => {
|
||||
const finish = (result: CodeRunResult): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearInterval(eluTimer)
|
||||
@@ -313,18 +344,28 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
||||
this.live.delete(live)
|
||||
void worker.terminate().then(() => {
|
||||
finishResolve()
|
||||
resolve({ ...result, logs: [...logs, ...strayLogs] })
|
||||
resolve(result)
|
||||
})
|
||||
}
|
||||
|
||||
const onDone = (message: WorkerToHost): void => {
|
||||
if (message.type !== 'done') return
|
||||
// Re-cap forged completion traffic at the hostile boundary. Honest worker-capped values
|
||||
// pass unchanged via VALUE_RENDER_SLACK; error text is bounded too.
|
||||
finish({
|
||||
...prepareValue(message.value, this.config.maxValueBytes + VALUE_RENDER_SLACK),
|
||||
...message.error ? { error: { kind: 'exception' as const, message: truncateUtf8Bytes(message.error.message, this.config.maxValueBytes) } } : {},
|
||||
})
|
||||
const captured = [...logs, ...strayLogs]
|
||||
if (message.error) {
|
||||
finish(output.failure(captured, message.error))
|
||||
return
|
||||
}
|
||||
if (message.value === undefined) {
|
||||
finish(output.success(captured))
|
||||
return
|
||||
}
|
||||
// The worker-thread boundary has already structured-cloned this
|
||||
// hostile value, so accessors and proxies cannot survive to throw
|
||||
// during the lossless-JSON snapshot.
|
||||
const value = snapshotJsonValue(message.value) as CodeJsonValue | undefined
|
||||
finish(value === undefined
|
||||
? output.failure(captured, { kind: 'invalid-output', message: 'program completion must be lossless JSON' })
|
||||
: output.success(captured, value))
|
||||
}
|
||||
|
||||
const onCall = (message: WorkerToHost): void => {
|
||||
@@ -336,13 +377,9 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
||||
answered.add(message.id)
|
||||
const reply = (payload: ReplyMessage): void => {
|
||||
if (settled) return
|
||||
try {
|
||||
worker.postMessage(payload)
|
||||
} catch {
|
||||
// The reply value failed structured clone; renegotiate as an error
|
||||
// reply, which is always clone-plain. Nothing else throws here.
|
||||
worker.postMessage({ type: 'reply', id: message.id, ok: false, message: 'binding resolution is not structured-cloneable' })
|
||||
}
|
||||
// Canonical resolutions were snapshotted as lossless JSON before
|
||||
// this point, so this payload is structured-cloneable by contract.
|
||||
worker.postMessage(payload)
|
||||
}
|
||||
const record = bindings.get(message.global)
|
||||
// Own-property lookup only: a forged name like 'constructor' or
|
||||
@@ -355,7 +392,18 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
reply({ type: 'reply', id: message.id, ok: true, value: await fn(message.args) })
|
||||
const resolved = await fn(message.args)
|
||||
let value: CodeJsonValue | undefined
|
||||
try {
|
||||
value = snapshotJsonValue(resolved)
|
||||
} catch {
|
||||
value = undefined
|
||||
}
|
||||
if (value === undefined) {
|
||||
reply({ type: 'reply', id: message.id, ok: false, message: 'binding resolution must be lossless JSON' })
|
||||
} else {
|
||||
reply({ type: 'reply', id: message.id, ok: true, value })
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
reply({ type: 'reply', id: message.id, ok: false, message: messageOf(error) })
|
||||
}
|
||||
@@ -367,15 +415,22 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
||||
// this listener would crash the host process. Junk drops silently.
|
||||
const message = parseWorkerMessage(raw)
|
||||
if (!message) return
|
||||
if (message.type === 'log' && !settled) admit(message.text, logs)
|
||||
if (message.type === 'log' && !settled && !output.admit(message.text, logs)) {
|
||||
finish(output.limit([...logs, ...strayLogs]))
|
||||
return
|
||||
}
|
||||
if (message.type === 'output-limit' && !settled) {
|
||||
finish(output.limit([...logs, ...strayLogs]))
|
||||
return
|
||||
}
|
||||
onCall(message)
|
||||
onDone(message)
|
||||
})
|
||||
worker.on('error', (error: Error) => {
|
||||
finish({ error: { kind: 'worker-exit', message: `worker error: ${error.message}` } })
|
||||
finish(output.failure([...logs, ...strayLogs], { kind: 'worker-exit', message: `worker error: ${error.message}` }))
|
||||
})
|
||||
worker.on('exit', (exitCode: number) => {
|
||||
finish({ error: { kind: 'worker-exit', message: `worker exited with code ${exitCode} before completing` } })
|
||||
finish(output.failure([...logs, ...strayLogs], { kind: 'worker-exit', message: `worker exited with code ${exitCode} before completing` }))
|
||||
})
|
||||
|
||||
// The compute budget reads the worker's own measured busy time, so a
|
||||
@@ -384,21 +439,21 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
||||
const eluTimer = setInterval(() => {
|
||||
const elu = worker.performance.eventLoopUtilization()
|
||||
if (elu.active > this.config.computeMs) {
|
||||
finish({ error: { kind: 'timeout', message: `compute budget exhausted (${this.config.computeMs}ms busy)` } })
|
||||
finish(output.failure([...logs, ...strayLogs], { kind: 'timeout', message: `compute budget exhausted (${this.config.computeMs}ms busy)` }))
|
||||
}
|
||||
}, ELU_POLL_INTERVAL_MS)
|
||||
const wallTimer = setTimeout(() => {
|
||||
finish({ error: { kind: 'timeout', message: `wall-clock ceiling reached (${this.config.maxWallMs}ms)` } })
|
||||
finish(output.failure([...logs, ...strayLogs], { kind: 'timeout', message: `wall-clock ceiling reached (${this.config.maxWallMs}ms)` }))
|
||||
}, this.config.maxWallMs)
|
||||
const onAbort = (): void => {
|
||||
finish({ error: { kind: 'abort', message: String(request.signal?.reason) } })
|
||||
finish(output.failure([...logs, ...strayLogs], { kind: 'abort', message: String(request.signal?.reason) }))
|
||||
}
|
||||
request.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
const live: LiveRun = {
|
||||
worker,
|
||||
finished,
|
||||
settle: (failure: CodeRunFailure) => { finish({ error: failure }) },
|
||||
settle: (failure: CodeRunFailure) => { finish(output.failure([...logs, ...strayLogs], failure)) },
|
||||
}
|
||||
this.live.add(live)
|
||||
})
|
||||
|
||||
@@ -11,10 +11,8 @@ export interface WorkerBootData {
|
||||
code: string
|
||||
/** Binding namespaces to materialize: the global name plus the function names (functions themselves stay host-side). */
|
||||
namespaces: { global: string; names: string[] }[]
|
||||
/** Shared byte budget for captured log text; exceeding it drops further entries after one in-band marker. */
|
||||
maxLogBytes: number
|
||||
/** Byte cap for the rendered completion value (see the value-preparation contract in bootstrap.ts). */
|
||||
maxValueBytes: number
|
||||
/** Hard cap for the combined serialized outer logs plus completion value or failure diagnostic. */
|
||||
maxOutputBytes: number
|
||||
}
|
||||
|
||||
/** Worker → host: one bridged binding call. */
|
||||
@@ -36,6 +34,11 @@ interface LogMessage {
|
||||
text: string
|
||||
}
|
||||
|
||||
/** Worker → host: worker-side capture or completion measurement exceeded the outer cap. */
|
||||
interface OutputLimitMessage {
|
||||
type: 'output-limit'
|
||||
}
|
||||
|
||||
/**
|
||||
* Worker → host: the program settled. `error` carries a program exception
|
||||
* (the only failure the bootstrap itself can report — budgets, aborts, and
|
||||
@@ -47,26 +50,13 @@ interface LogMessage {
|
||||
export interface DoneMessage {
|
||||
type: 'done'
|
||||
value?: unknown
|
||||
error?: { message: string }
|
||||
error?: { kind: 'exception' | 'invalid-output' | 'output-limit'; message: string }
|
||||
}
|
||||
|
||||
/** Every message the worker sends. */
|
||||
export type WorkerToHost = CallMessage | LogMessage | DoneMessage
|
||||
export type WorkerToHost = CallMessage | LogMessage | OutputLimitMessage | DoneMessage
|
||||
|
||||
/** Host → worker: the answer to one {@link CallMessage}. */
|
||||
export type ReplyMessage =
|
||||
| { type: 'reply'; id: number; ok: true; value: unknown }
|
||||
| { type: 'reply'; id: number; ok: false; message: string }
|
||||
|
||||
/**
|
||||
* The in-band marker entry text announcing that log capture stopped at the
|
||||
* byte budget. Shared wire vocabulary: the worker's LogBuffer emits it when
|
||||
* ITS budget exhausts, and the host emits the identical text when its own
|
||||
* ledger drops an entry first (forged port traffic, stray pipe bytes) — so
|
||||
* a truncated run reads the same however the cap was hit.
|
||||
* @param maxBytes - the configured `maxLogBytes` the marker names.
|
||||
* @returns the marker line.
|
||||
*/
|
||||
export function logTruncationMarker(maxBytes: number): string {
|
||||
return `[dsh-code-runtime-worker] log capture truncated at ${maxBytes} bytes`
|
||||
}
|
||||
Reference in New Issue
Block a user