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:
Tianyi Cui
2026-07-08 21:56:28 +08:00
parent 030eebb634
commit 90547f283b
4 changed files with 132 additions and 11 deletions
@@ -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) } } : {},
})
}