From e76b3baf9e7db9cfc864c8b56cb69b313d579d9b Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 3 Aug 2026 00:05:07 +0800 Subject: [PATCH] fix(code-runtime-python): meter stdout and stderr stray residual against one shared budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stdout and stderr each checked their pending serialized cost against the full logBudget independently, so both could retain nearly a budget's worth of newline-free residual at once — double the intended peak, up to ~512 MiB near the ceiling. The flush threshold now reads the COMBINED cost of both pipes and flushes both when it crosses, since they share one ledger. Remove the post-truncation admit() v8-ignore: captureStray's per-line loop makes that branch deterministically reachable within one data callback (a chunk whose first newline-terminated line exhausts the budget hits it on the second), so it is measured by a new regression test rather than ignored. Refresh two stray-output test comments that still named the removed StringDecoder; the raw-chunk buffer reassembles a split multibyte sequence by concatenating before it decodes, and the end flush renders a stranded partial as U+FFFD via toString('utf8'). --- ...-runtime-python-settlement-fixes.i18n.yaml | 4 +- ...31-code-runtime-python-settlement-fixes.md | 2 +- ...code-runtime-python-settlement-fixes.zh.md | 2 +- .../code-runtime-python/src/index.ts | 40 ++++++++++++------- .../code-runtime-python/tests/runtime.spec.ts | 26 ++++++++++-- 5 files changed, 51 insertions(+), 23 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.i18n.yaml index 922d1bdfbd..39f544ac3e 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.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 .agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.md -2026-07-31-code-runtime-python-settlement-fixes.md: aa8e4ce513b9b9c4aaa07b5363bc497c6a4d0a9c -2026-07-31-code-runtime-python-settlement-fixes.zh.md: 36a5d5d06286ee07db3564f6f62c0f9d79288391 +2026-07-31-code-runtime-python-settlement-fixes.md: 669268f9b128e98b9af6b221d4add86e0258016d +2026-07-31-code-runtime-python-settlement-fixes.zh.md: 003c42ebb0a7634539e510fc780e237fd36a6a29 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.md b/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.md index aa8e4ce513..669268f9b1 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.md @@ -54,7 +54,7 @@ Also in `src/index.ts`, `spawn` is called before the settlement Promise executor ### Stray pipe output is aggregated by line, not by transport chunk -Also in `src/index.ts`, native stdout/stderr bytes (C-extension writes, `os.write` past the pipe buffer) were pushed to `logs` one entry per Node `data` chunk. `logs` entries are joined with `\n` downstream (Code Mode), so a single newline-free write larger than one pipe read — arriving as several `data` chunks — read back with model-visible newlines inserted at arbitrary transport boundaries. Capture now accumulates raw `Buffer` chunks (the same shape as the fd-3 reader, and for the same reasons: a string `+=` accumulator re-copies the whole residual per chunk and scanning it from index 0 each chunk is a second quadratic — both O(N²) on a large newline-free write), splits on the raw `0x0a` byte, and admits one entry per complete line. A newline never appears inside a UTF-8 multibyte sequence, so decoding each split line is safe without a streaming decoder. Three separate bounds keep the residual from exhausting host memory, each mirroring the fd-3 reader: the fragment list SEALS into finished blocks past `MAX_PENDING_CHUNKS` so a program pacing single-byte `os.write`s cannot accumulate millions of live Buffer objects (whose per-object overhead no byte count sees); the residual is flushed when its running SERIALIZED cost — tracked per byte through `serializedBufferCost`, a lower bound on the admitted line's exact cost — would cross the budget, so a control-char-dense flood flushes at roughly a sixth of the raw bytes rather than accumulating a full budget's worth of raw bytes first; and once the ledger has truncated, buffering stops so nothing accumulates for output that can never be admitted. The per-entry charge itself is metered by SERIALIZED cost through `jsonStringCostUpTo`, which walks the string to the cap and stops — the previous `Buffer.byteLength(JSON.stringify(text))` allocated the whole escaped form first, so a near-budget control-char-dense line under a large `maxLogBytes` could momentarily allocate over a gigabyte just to measure it. Both cost functions charge a LONE surrogate the full six escaped bytes (`\uXXXX` under ES2019 well-formed `JSON.stringify`), not the three bytes `Buffer.byteLength` reports for its U+FFFD rendering: a forged `log` frame flooding `\ud800` escapes would otherwise be undercharged by half and admit roughly twice the budget. The residual is flushed on the pipe's `end` and also explicitly in the `closeDeadline` handler before it destroys the streams: a `setsid` escapee holding the pipes open forces settlement through that path without an `end`, so a final newline-free `os.write(1, …)` the leader emitted before exiting would otherwise be dropped from `logs`. +Also in `src/index.ts`, native stdout/stderr bytes (C-extension writes, `os.write` past the pipe buffer) were pushed to `logs` one entry per Node `data` chunk. `logs` entries are joined with `\n` downstream (Code Mode), so a single newline-free write larger than one pipe read — arriving as several `data` chunks — read back with model-visible newlines inserted at arbitrary transport boundaries. Capture now accumulates raw `Buffer` chunks (the same shape as the fd-3 reader, and for the same reasons: a string `+=` accumulator re-copies the whole residual per chunk and scanning it from index 0 each chunk is a second quadratic — both O(N²) on a large newline-free write), splits on the raw `0x0a` byte, and admits one entry per complete line. A newline never appears inside a UTF-8 multibyte sequence, so decoding each split line is safe without a streaming decoder. Three separate bounds keep the residual from exhausting host memory, each mirroring the fd-3 reader: the fragment list SEALS into finished blocks past `MAX_PENDING_CHUNKS` so a program pacing single-byte `os.write`s cannot accumulate millions of live Buffer objects (whose per-object overhead no byte count sees); the residual is flushed when the COMBINED running SERIALIZED cost of both pipes — tracked per byte through `serializedBufferCost`, a lower bound on the admitted line's exact cost — would cross the budget, so a control-char-dense flood flushes at roughly a sixth of the raw bytes rather than accumulating a full budget's worth of raw bytes first, and stdout and stderr are metered together rather than each against the full budget (which would let both retain nearly a budget's worth at once, doubling the peak); and once the ledger has truncated, buffering stops so nothing accumulates for output that can never be admitted. The per-entry charge itself is metered by SERIALIZED cost through `jsonStringCostUpTo`, which walks the string to the cap and stops — the previous `Buffer.byteLength(JSON.stringify(text))` allocated the whole escaped form first, so a near-budget control-char-dense line under a large `maxLogBytes` could momentarily allocate over a gigabyte just to measure it. Both cost functions charge a LONE surrogate the full six escaped bytes (`\uXXXX` under ES2019 well-formed `JSON.stringify`), not the three bytes `Buffer.byteLength` reports for its U+FFFD rendering: a forged `log` frame flooding `\ud800` escapes would otherwise be undercharged by half and admit roughly twice the budget. The residual is flushed on the pipe's `end` and also explicitly in the `closeDeadline` handler before it destroys the streams: a `setsid` escapee holding the pipes open forces settlement through that path without an `end`, so a final newline-free `os.write(1, …)` the leader emitted before exiting would otherwise be dropped from `logs`. ## Testing diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.zh.md index 36a5d5d062..003c42ebb0 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.zh.md @@ -54,7 +54,7 @@ Status: implemented ### Stray pipe output is aggregated by line, not by transport chunk -同样在 `src/index.ts` 中,原生 stdout/stderr 字节(C 扩展写入、越过管道缓冲区的 `os.write`)过去每来一个 Node `data` 分片就被推入 `logs` 一条条目。`logs` 条目在下游(Code Mode)会用 `\n` 拼接,因此一次大于单次管道读取、且不含换行符的写入——它以若干个 `data` 分片到达——回读时会在任意传输边界处被插入模型可见的换行符。现在捕获会累积原始 `Buffer` 分片(与 fd-3 读取器同一形态,出于同样的原因:一个字符串 `+=` 累加器会为每个分片重新复制整份残余数据,而每个分片都从索引 0 扫描它则是第二重平方——在一次大的不含换行符的写入上二者都是 O(N²)),在原始的 `0x0a` 字节处切分,并为每个完整行准入一条条目。换行符绝不会出现在一个 UTF-8 多字节序列内部,因此对每个切出的行做解码无需流式解码器即可安全进行。三条相互独立的界限使残余数据不至于耗尽宿主内存,每一条都与 fd-3 读取器相对应:分片列表在越过 `MAX_PENDING_CHUNKS` 后会封存(SEAL)为已完成的块,因此一个以单字节 `os.write` 控速的程序无法累积起数以百万计的存活 Buffer 对象(其逐对象开销是任何字节计数都看不到的);当残余数据持续推进的序列化(SERIALIZED)开销——通过 `serializedBufferCost` 逐字节跟踪,它是被准入行确切开销的一个下界——将要越过预算时,残余数据会被冲刷,因此一场控制字符密集的洪泛会在大约六分之一的原始字节处就冲刷,而不是先累积起满满一个预算份额的原始字节;而一旦账本已经截断,缓冲便停止,从而不会为永远无法被准入的输出累积任何内容。每条条目的计费本身通过 `jsonStringCostUpTo` 按序列化开销计量,它把字符串走到上限即停止——先前的 `Buffer.byteLength(JSON.stringify(text))` 会先分配出整份转义后的形式,因此在一个较大的 `maxLogBytes` 之下,一行接近预算、控制字符密集的内容,仅仅为了度量它就可能瞬时分配超过一 GB。两个开销函数都会给一个孤立(LONE)代理项计满六个转义字节(在 ES2019 良构 `JSON.stringify` 下为 `\uXXXX`),而不是 `Buffer.byteLength` 为其 U+FFFD 渲染所报告的三个字节:否则一个伪造的、以 `\ud800` 转义洪泛的 `log` 帧会被少计一半,并放行大约两倍于预算的内容。该残余数据会在管道 `end` 时冲刷,也会在 `closeDeadline` 处理器销毁流之前被显式冲刷:一个持有管道不放的 `setsid` 逃逸者会迫使结算在没有 `end` 的情况下走那条路径,因此 leader 在退出前发出的最后一次不含换行符的 `os.write(1, …)` 否则会从 `logs` 中被丢弃。 +同样在 `src/index.ts` 中,原生 stdout/stderr 字节(C 扩展写入、越过管道缓冲区的 `os.write`)过去每来一个 Node `data` 分片就被推入 `logs` 一条条目。`logs` 条目在下游(Code Mode)会用 `\n` 拼接,因此一次大于单次管道读取、且不含换行符的写入——它以若干个 `data` 分片到达——回读时会在任意传输边界处被插入模型可见的换行符。现在捕获会累积原始 `Buffer` 分片(与 fd-3 读取器同一形态,出于同样的原因:一个字符串 `+=` 累加器会为每个分片重新复制整份残余数据,而每个分片都从索引 0 扫描它则是第二重平方——在一次大的不含换行符的写入上二者都是 O(N²)),在原始的 `0x0a` 字节处切分,并为每个完整行准入一条条目。换行符绝不会出现在一个 UTF-8 多字节序列内部,因此对每个切出的行做解码无需流式解码器即可安全进行。三条相互独立的界限使残余数据不至于耗尽宿主内存,每一条都与 fd-3 读取器相对应:分片列表在越过 `MAX_PENDING_CHUNKS` 后会封存(SEAL)为已完成的块,因此一个以单字节 `os.write` 控速的程序无法累积起数以百万计的存活 Buffer 对象(其逐对象开销是任何字节计数都看不到的);当两个管道合并(COMBINED)的持续推进序列化(SERIALIZED)开销——通过 `serializedBufferCost` 逐字节跟踪,它是被准入行确切开销的一个下界——将要越过预算时,残余数据会被冲刷,因此一场控制字符密集的洪泛会在大约六分之一的原始字节处就冲刷,而不是先累积起满满一个预算份额的原始字节,并且 stdout 与 stderr 是合并计量的,而不是各自对照完整预算(那样会让两者同时各保留将近一个预算份额,使峰值翻倍);而一旦账本已经截断,缓冲便停止,从而不会为永远无法被准入的输出累积任何内容。每条条目的计费本身通过 `jsonStringCostUpTo` 按序列化开销计量,它把字符串走到上限即停止——先前的 `Buffer.byteLength(JSON.stringify(text))` 会先分配出整份转义后的形式,因此在一个较大的 `maxLogBytes` 之下,一行接近预算、控制字符密集的内容,仅仅为了度量它就可能瞬时分配超过一 GB。两个开销函数都会给一个孤立(LONE)代理项计满六个转义字节(在 ES2019 良构 `JSON.stringify` 下为 `\uXXXX`),而不是 `Buffer.byteLength` 为其 U+FFFD 渲染所报告的三个字节:否则一个伪造的、以 `\ud800` 转义洪泛的 `log` 帧会被少计一半,并放行大约两倍于预算的内容。该残余数据会在管道 `end` 时冲刷,也会在 `closeDeadline` 处理器销毁流之前被显式冲刷:一个持有管道不放的 `setsid` 逃逸者会迫使结算在没有 `end` 的情况下走那条路径,因此 leader 在退出前发出的最后一次不含换行符的 `os.write(1, …)` 否则会从 `logs` 中被丢弃。 ## Testing diff --git a/packages/code-runtime/code-runtime-python/src/index.ts b/packages/code-runtime/code-runtime-python/src/index.ts index 9eddcb01df..d9586010dc 100644 --- a/packages/code-runtime/code-runtime-python/src/index.ts +++ b/packages/code-runtime/code-runtime-python/src/index.ts @@ -767,7 +767,10 @@ export class PythonCodeRuntime extends CodeRuntime { let logBudget = this.config.maxLogBytes let logsTruncated = false const admit = (text: string): void => { - /* v8 ignore next -- post-truncation admits no-op; needs child to keep streaming after ledger drops. */ + // Post-truncation admits are no-ops: once the ledger has truncated, the + // marker is the last entry. Reachable within one `data` callback — a + // chunk carrying two newline-terminated lines where the first exhausts + // the budget hits this on the second — so it is a measured branch. if (logsTruncated) return // Each entry is charged its SERIALIZED cost — JSON.stringify's quotes // and escapes plus one separator byte — because the seam bounds the @@ -872,22 +875,29 @@ export class PythonCodeRuntime extends CodeRuntime { } // Newline-free residual is bounded by the ledger, not left to grow with // the stream: an `os.write(1, b"A"*N)` flood carrying no newline would - // otherwise accumulate N bytes in host memory before `end`. When the - // pending residual's serialized cost would cross the budget, flush it now - // — admit() charges the exact serialized cost, truncates, and marks the - // ledger, and the truncation short-circuit above stops buffering on the - // next chunk. `+ 3` covers the two quotes and one separator admit adds. - if (stray.cost + 3 > logBudget) { - flushStray(stray) + // otherwise accumulate N bytes in host memory before `end`. The bound is + // on the COMBINED pending cost of both pipes, not each alone: stdout and + // stderr share one `logBudget`, so checking each against the full budget + // independently would let both retain nearly a budget's worth at once — + // ~2x peak, up to ~512 MiB near the ceiling — before either flushed. + // When the sum would cross the budget, flush both now. admit() charges + // the exact serialized cost, truncates, and marks the ledger, and the + // truncation short-circuit above stops buffering on the next chunk. + // `+ 3` covers the two quotes and one separator admit adds. + if (strayOut.cost + strayErr.cost + 3 > logBudget) { + flushStray(strayOut) + flushStray(strayErr) } } - // Flush a pipe's residual into `logs`. Called on the budget threshold - // above, on the pipe's `end` (normal drain), and — for the setsid-escapee - // path where destroy() forces settlement without an `end` — explicitly in - // the closeDeadline handler. Idempotent: it clears what it admits, so a - // later flush is a no-op. The `chunks`/`blocks` guard is the only emptiness - // check needed — `data` never emits a zero-length Buffer, so a non-empty - // fragment list always decodes to a non-empty tail. + // Flush a pipe's residual into `logs`. Called on the combined-budget + // threshold above, on the pipe's `end` (normal drain), and — for the + // setsid-escapee path where destroy() forces settlement without an `end` — + // explicitly in the closeDeadline handler. Idempotent: it clears what it + // admits, so a later flush is a no-op, and it returns early on an empty + // buffer so flushing the sibling that had nothing pending is a no-op. The + // `chunks`/`blocks` guard is the only emptiness check needed — `data` never + // emits a zero-length Buffer, so a non-empty fragment list always decodes + // to a non-empty tail. function flushStray(stray: StrayBuffer): void { if (stray.chunks.length === 0 && stray.blocks.length === 0) return const tail = Buffer.concat([...stray.blocks, ...stray.chunks]).toString('utf8') diff --git a/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts b/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts index ac962ce270..067b8c48b0 100644 --- a/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts +++ b/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts @@ -774,6 +774,22 @@ describe('PythonCodeRuntime — programs and bindings', () => { expect(result.logs.at(-1)).toBe(logTruncationMarker(4096)) }) + it('drops a second stray line in the same chunk once the first truncated the ledger', async () => { + // One `os.write` carrying two newline-terminated lines where the first + // exhausts maxLogBytes: the first line's admit truncates and marks the + // ledger, and the second line's admit — reached in the same `data` callback + // — must be the post-truncation no-op. Proves that branch is exercised, so + // it carries no v8-ignore. + const { runtime } = await setup({ maxLogBytes: 64 }) + const result = await runtime.run({ + program: ['import os', 'os.write(1, b"A" * 5000 + b"\\nSECOND\\n")', 'return None'].join('\n'), + bindings: [], + }) + expect(result.error).toBeUndefined() + expect(result.logs.at(-1)).toBe(logTruncationMarker(64)) + expect(result.logs.join('\n')).not.toContain('SECOND') + }) + it('charges the exact serialized cost of short-escape and quote/backslash characters', async () => { // Exercises every branch of jsonStringCostUpTo's per-character cost: a tab // and other C0 controls with short JSON forms (\t etc., 2 bytes), a quote @@ -3440,7 +3456,9 @@ describe('PythonCodeRuntime — hostile peer', () => { // A single os.write far past the 64 KiB pipe buffer forces multiple // 'data' chunks; when the boundary lands inside the emoji's 4-byte // sequence, per-chunk decoding would corrupt it into replacement - // characters. The streaming decoder must reassemble it. + // characters. Raw bytes are buffered and only decoded once a complete line + // (or the whole tail at flush) is assembled, so the split sequence is whole + // by the time it is decoded. const { runtime } = await setup({ maxLogBytes: 1024 * 1024 }) const result = await runtime.run({ program: [ @@ -3464,9 +3482,9 @@ describe('PythonCodeRuntime — hostile peer', () => { it('flushes a stray-output byte sequence left incomplete when the pipe ends', async () => { // The child writes the first two bytes of a 3-byte UTF-8 character to fd 1 - // and exits, so the pipe closes with the sequence unfinished inside the - // streaming decoder. The 'end' flush must render the stranded bytes as - // U+FFFD instead of dropping the evidence with the decoder. + // and exits, so the pipe closes with the sequence unfinished in the raw + // residual. The 'end' flush decodes the residual with `toString('utf8')`, + // which renders the stranded bytes as U+FFFD instead of dropping them. const { runtime } = await setup({ maxLogBytes: 1024 * 1024 }) const result = await runtime.run({ program: [