fix: byte-exact value/error caps + write-callback contract (agent review)
Two [P1] review findings on the worker runtime:
- maxValueBytes gated and sliced the rendered fallback by UTF-16 code
units, so a multibyte string ("€€€€" under a 4-byte cap) crossed whole
and a truncated multibyte rendering could still run ~3x over budget.
New truncateUtf8Bytes cuts at code-point boundaries under a real byte
budget; prepareValue's fallback and the host's forged-error-text bound
both use it, and the VALUE_RENDER_SLACK comment drops its now-obsolete
"sliced by characters" wrinkle.
- The patched stream write dropped Node's optional encoding/callback
arguments, so a program awaiting flush completion
(write(chunk, resolve)) hung to the wall ceiling and misreported as a
timeout. The shim now fires the callback asynchronously once the chunk
is admitted — including for writes the exhausted budget drops.
This commit is contained in:
@@ -98,6 +98,10 @@ export function makeConsoleShim(logs: LogBuffer): Record<(typeof CONSOLE_LEVELS)
|
||||
* Redirect a stream's `write` into the log buffer (the program-visible
|
||||
* `process.stdout`/`process.stderr` in the real worker), so raw writes land
|
||||
* in emission order alongside console output instead of racing down a pipe.
|
||||
* The shim keeps Node's `write(chunk[, encoding][, callback])` contract: the
|
||||
* callback fires asynchronously once the chunk is admitted (a program
|
||||
* awaiting flush completion must complete, not sit until the wall timeout),
|
||||
* even for writes the exhausted budget drops.
|
||||
* @param logs - the buffer captured writes are pushed into.
|
||||
* @param stream - the stream whose `write` slot is patched.
|
||||
* @param source - the log source the captured writes are attributed to.
|
||||
@@ -109,8 +113,14 @@ export function captureStreamWrites(logs: LogBuffer, stream: PatchableStream, so
|
||||
// detached, so the unbound-method concern does not apply.
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const original = stream.write
|
||||
stream.write = (chunk: unknown): boolean => {
|
||||
stream.write = (chunk: unknown, ...rest: unknown[]): boolean => {
|
||||
logs.push({ source, text: typeof chunk === 'string' ? chunk : String(chunk) })
|
||||
// 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) })
|
||||
return true
|
||||
}
|
||||
return () => { stream.write = original }
|
||||
@@ -119,6 +129,28 @@ export function captureStreamWrites(logs: LogBuffer, stream: PatchableStream, so
|
||||
/** 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: a value whose
|
||||
* MEASURED cross-boundary size fits `maxValueBytes` crosses raw — exact
|
||||
@@ -126,9 +158,10 @@ const INSPECT_OPTIONS = { depth: 4, maxArrayLength: 100, maxStringLength: 10_000
|
||||
* everything else, so a huge container whose BOUNDED inspect rendering
|
||||
* happens to be small cannot smuggle itself past the cap. Anything else
|
||||
* (non-cloneable, or oversized) is REPLACED by its bounded `util.inspect`
|
||||
* rendering, truncated with an in-band marker — the seam contract's "a
|
||||
* non-transferable value is replaced by a string rendering", extended to
|
||||
* oversized ones so a huge return cannot flood the host.
|
||||
* rendering, byte-truncated ({@link truncateUtf8Bytes}) with an in-band
|
||||
* marker — the seam contract's "a non-transferable value is replaced by a
|
||||
* string rendering", extended to oversized ones so a huge return cannot
|
||||
* flood the host.
|
||||
* @param value - the program's completion value.
|
||||
* @param maxValueBytes - the byte cap for the value.
|
||||
* @returns the done-message fragment: `{}` for `undefined`, else `{ value }`.
|
||||
@@ -150,7 +183,9 @@ export function prepareValue(value: unknown, maxValueBytes: number): { value?: u
|
||||
if (size !== undefined && size <= maxValueBytes) return { value }
|
||||
}
|
||||
const rendered = typeof value === 'string' ? value : inspect(value, INSPECT_OPTIONS)
|
||||
const capped = rendered.length > maxValueBytes ? `${rendered.slice(0, maxValueBytes)}… [truncated]` : rendered
|
||||
const capped = Buffer.byteLength(rendered, 'utf8') > maxValueBytes
|
||||
? `${truncateUtf8Bytes(rendered, maxValueBytes)}… [truncated]`
|
||||
: rendered
|
||||
return { value: capped }
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
||||
import type { CodeBindingFunction, CodeLogEntry, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
|
||||
import { prepareValue } from './bootstrap.ts'
|
||||
import { prepareValue, truncateUtf8Bytes } from './bootstrap.ts'
|
||||
import { logTruncationMarker } from './protocol.ts'
|
||||
import type { ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
|
||||
|
||||
@@ -166,9 +166,8 @@ function parseWorkerMessage(raw: unknown): WorkerToHost | 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 passes through unchanged instead of being marked twice.
|
||||
* (A multibyte rendering the worker sliced by characters can still exceed
|
||||
* this and pick up a second marker — bounded and harmless.)
|
||||
* already capped (byte-exact prefix + this marker) passes through unchanged
|
||||
* instead of being marked twice.
|
||||
*/
|
||||
const VALUE_RENDER_SLACK = Buffer.byteLength('… [truncated]', 'utf8')
|
||||
|
||||
@@ -357,7 +356,7 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
||||
// unchanged (see VALUE_RENDER_SLACK); the error text is bounded too.
|
||||
finish({
|
||||
...prepareValue(message.value, this.config.maxValueBytes + VALUE_RENDER_SLACK),
|
||||
...message.error ? { error: { kind: 'exception' as const, message: message.error.message.slice(0, this.config.maxValueBytes) } } : {},
|
||||
...message.error ? { error: { kind: 'exception' as const, message: truncateUtf8Bytes(message.error.message, this.config.maxValueBytes) } } : {},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user