From bb6709b14ac342f848905f4d6400a625fb93fb51 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:42:41 +0800 Subject: [PATCH] fix(code-runtime): bound completion measurement --- ...26-07-20-code-mode-typed-tool-returns.i18n.yaml | 4 ++-- .../2026-07-20-code-mode-typed-tool-returns.md | 2 +- .../2026-07-20-code-mode-typed-tool-returns.zh.md | 2 +- .../code-runtime-worker/src/bootstrap.ts | 6 +++--- .../code-runtime/code-runtime-worker/src/index.ts | 14 +++++++------- .../code-runtime-worker/tests/runtime.spec.ts | 9 +++++++++ .../tests/source-worker.compat.spec.ts | 2 +- 7 files changed, 24 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml index 7981bedde0..a4cf2dab15 100644 --- a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-20-code-mode-typed-tool-returns.md: 42fe44c1b6f6a0d0debc9d8e1f742b436cd22047 -2026-07-20-code-mode-typed-tool-returns.zh.md: c567fa86bbe381b9bd54ed37d83fbc0e18252b51 +2026-07-20-code-mode-typed-tool-returns.md: 089773cc715b9003ab4e1667e5af36a8aa1686d4 +2026-07-20-code-mode-typed-tool-returns.zh.md: 24e41ae49182e2d1976e451601cfc8008b1ed11a diff --git a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md index 42fe44c1b6..089773cc71 100644 --- a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md +++ b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md @@ -59,7 +59,7 @@ Binding arguments and resolutions are revalidated as lossless JSON on both sides The runtime accepts an exact lossless JSON completion of any root. Returning `undefined` omits the completion; returning `null` is an explicit result. `run_code` exposes the canonical outer value `{ logs: string[], result?: JsonValue }`. Its Native renderer emits logs first, renders a string result raw, and pretty-prints every other JSON root. -`WorkerCodeRuntime` replaces the former independent log and value caps with configurable `maxOutputBytes`, defaulting to `67_108_864` bytes. One host-side hostile-peer ledger accounts the JSON serialization of the outer logs array plus either the completion value or failure diagnostic. A result at or below the cap is exact. A completion that cannot survive lossless JSON snapshotting fails as `invalid-output`; a value or combined logs/value outcome over the cap fails as `output-limit` rather than becoming inspected or truncated text. +`WorkerCodeRuntime` replaces the former independent log and value caps with configurable `maxOutputBytes`, defaulting to `67_108_864` bytes. The worker preflights the detached completion with bounded JSON measurement, and one host-side hostile-peer ledger accounts the JSON serialization of the outer logs array plus either the completion value or failure diagnostic. Neither stage materializes an over-limit serialized completion. A result at or below the cap is exact. A completion that cannot survive lossless JSON snapshotting fails as `invalid-output`; a value or combined logs/value outcome over the cap fails as `output-limit` rather than becoming inspected or truncated text. Logs stream eagerly so a terminated run can retain output already admitted. Native stdout and stderr writes that bypass the worker's patched stream slots use independent pipes, so terminal settlement continues bounded capture until worker termination completes before materializing the result. When the cap is crossed, the runtime returns an explicit bounded failure with the fitting captured prefix. That outer result then traverses the ordinary `run_code` rendering and spill policy, which may save the captured text and expose its configured head/tail preview. The spill layer cannot recover bytes the runtime rejected beyond the hard cap. diff --git a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md index c567fa86bb..24e41ae491 100644 --- a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md @@ -59,7 +59,7 @@ worker 暴露的是真正用于 `tools` 绑定失败的 `ToolCallError` 构造 运行时接受以任意 JSON 类型为根的精确无损完成值。返回 `undefined` 表示省略完成值;返回 `null` 则是显式结果。`run_code` 暴露规范外层值 `{ logs: string[], result?: JsonValue }`。其 Native 渲染器先输出日志;字符串结果保持原文,其他所有 JSON 根值采用美化格式输出。 -`WorkerCodeRuntime` 以可配置的 `maxOutputBytes` 取代彼此独立的日志与值上限,默认值为 `67_108_864` 字节。宿主侧为不可信对端维护一份统一账本,计入外层日志数组以及完成值或失败诊断的 JSON 序列化大小。结果不超过上限时会保持精确。完成值无法通过无损 JSON 快照时,以 `invalid-output` 失败;值本身或日志与值的组合超过上限时,以 `output-limit` 失败,而不会变成检查格式化后或截断的文本。 +`WorkerCodeRuntime` 以可配置的 `maxOutputBytes` 取代彼此独立的日志与值上限,默认值为 `67_108_864` 字节。worker 会先用有界 JSON 计量对分离后的完成值执行预检,宿主侧则为不可信对端维护一份统一账本,计入外层日志数组以及完成值或失败诊断的 JSON 序列化大小。这两个阶段都不会实际生成超出上限的完成值序列化结果。结果不超过上限时会保持精确。完成值无法通过无损 JSON 快照时,以 `invalid-output` 失败;值本身或日志与值的组合超过上限时,以 `output-limit` 失败,而不会变成检查格式化后或截断的文本。 日志会在产生时立即流出,因此运行被终止时仍可保留已经纳入额度的输出。绕过 worker 中已改写流写入入口的原生 stdout 和 stderr 写入会经由彼此独立的管道传输,因此运行时在终态结算期间仍会继续在上限内捕获输出,直至 worker 完全终止,然后才组装结果。超过上限后,运行时会返回一个显式的有界失败,并携带可容纳的已捕获前缀。该外层结果随后通过普通的 `run_code` 渲染与输出落盘策略;策略可以保存已捕获的文本,并暴露其配置指定的头尾预览。输出落盘层无法恢复运行时在硬上限之外拒绝的字节。 diff --git a/packages/code-runtime/code-runtime-worker/src/bootstrap.ts b/packages/code-runtime/code-runtime-worker/src/bootstrap.ts index 2334085377..28c0919749 100644 --- a/packages/code-runtime/code-runtime-worker/src/bootstrap.ts +++ b/packages/code-runtime/code-runtime-worker/src/bootstrap.ts @@ -7,6 +7,7 @@ import { inspect } from 'node:util' import type { DoneMessage, ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts' +import { jsonValueBytesUpTo } from './output-json.ts' import { snapshotCodeJsonValue } from './worker-json.ts' /** The port surface the bootstrap needs — satisfied by `parentPort` and by the tests' fake. */ @@ -155,7 +156,7 @@ export function truncateUtf8Bytes(text: string, maxBytes: number): string { */ export function prepareCompletion(value: unknown, maxOutputBytes: number): Omit { if (value === undefined) return {} - let snapshot: unknown + let snapshot: ReturnType try { snapshot = snapshotCodeJsonValue(value) } catch { @@ -164,8 +165,7 @@ export function prepareCompletion(value: unknown, maxOutputBytes: number): Omit< 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) { + if (jsonValueBytesUpTo(snapshot, maxOutputBytes) === undefined) { return { error: { kind: 'output-limit', message: `outer output exceeded ${maxOutputBytes} bytes` } } } return { value: snapshot } diff --git a/packages/code-runtime/code-runtime-worker/src/index.ts b/packages/code-runtime/code-runtime-worker/src/index.ts index 121b1a47d4..568d6558ca 100644 --- a/packages/code-runtime/code-runtime-worker/src/index.ts +++ b/packages/code-runtime/code-runtime-worker/src/index.ts @@ -122,6 +122,7 @@ function waitForPipeDrain(stream: Readable): Promise { stream.once('error', done) // Close the event-registration race if termination finished between the // initial state check and the listeners above. + /* v8 ignore next -- this race cannot be scheduled deterministically between the adjacent state check and listener registration. */ if (stream.readableEnded || stream.destroyed) done() }) } @@ -362,12 +363,13 @@ export class WorkerCodeRuntime extends CodeRuntime { // that were already queued; `finish` materializes the result only after // termination completes. const captureStray = (chunk: Buffer): void => { + /* v8 ignore next -- a second post-overflow chunk races immediate worker termination; the first overflow path is covered. */ if (terminalOverride !== undefined) return const text = chunk.toString('utf8') if (!output.admit(text, strayLogs)) { const limited = output.limit([...logs, ...strayLogs, text]) terminalOverride = limited - finish(() => limited) + finish(limited) } } worker.stdout.on('data', captureStray) @@ -377,7 +379,7 @@ export class WorkerCodeRuntime extends CodeRuntime { // logs captured before timeout, abort, or failure remain in the result. let finishResolve!: () => void const finished = new Promise((done) => { finishResolve = done }) - const finish = (finalize: () => CodeRunResult): void => { + const finish = (finalize: CodeRunResult | (() => CodeRunResult)): void => { if (settled) return settled = true clearInterval(eluTimer) @@ -390,7 +392,7 @@ export class WorkerCodeRuntime extends CodeRuntime { const stdoutDrained = waitForPipeDrain(worker.stdout) const stderrDrained = waitForPipeDrain(worker.stderr) await Promise.all([worker.terminate(), stdoutDrained, stderrDrained]) - const result = terminalOverride ?? finalize() + const result = terminalOverride ?? (typeof finalize === 'function' ? finalize() : finalize) finishResolve() resolve(result) }) @@ -474,14 +476,12 @@ export class WorkerCodeRuntime extends CodeRuntime { if (!message) return if (message.type === 'log' && !settled && !output.admit(message.text, logs)) { const limited = output.limit([...logs, ...strayLogs, message.text]) - terminalOverride = limited - finish(() => limited) + finish(limited) return } if (message.type === 'output-limit' && !settled) { const limited = output.limit([...logs, ...strayLogs]) - terminalOverride = limited - finish(() => limited) + finish(limited) return } onCall(message) diff --git a/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts b/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts index 4d73c859ce..f8b5210ee9 100644 --- a/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts +++ b/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts @@ -219,6 +219,15 @@ describe('WorkerCodeRuntime — budgets and containment (real workers)', () => { expect(after.value).toBe('alive') }, 30_000) + it('reports a worker that exits before publishing a completion', async () => { + const { runtime } = await setup() + const result = await runtime.run({ program: 'process.exit(7)', bindings: [] }) + expect(result).toEqual({ + logs: [], + error: { kind: 'worker-exit', message: 'worker exited with code 7 before completing' }, + }) + }) + it('fails runaway log output explicitly while retaining a bounded prefix', async () => { const { runtime } = await setup({ maxOutputBytes: 300 }) const result = await runtime.run({ diff --git a/packages/code-runtime/code-runtime-worker/tests/source-worker.compat.spec.ts b/packages/code-runtime/code-runtime-worker/tests/source-worker.compat.spec.ts index 5b71a9a94a..ee35b0e990 100644 --- a/packages/code-runtime/code-runtime-worker/tests/source-worker.compat.spec.ts +++ b/packages/code-runtime/code-runtime-worker/tests/source-worker.compat.spec.ts @@ -13,7 +13,7 @@ it('boots the source worker without workspace package outputs', async () => { const directory = await mkdtemp(join(tmpdir(), 'dsh-code-source-worker-')) let worker: Worker | undefined try { - const files = ['worker.ts', 'bootstrap.ts', 'protocol.ts', 'worker-json.ts'] + const files = ['worker.ts', 'bootstrap.ts', 'protocol.ts', 'worker-json.ts', 'output-json.ts'] await Promise.all(files.map(async (file) => { await copyFile(new URL(`../src/${file}`, import.meta.url), join(directory, file)) }))