fix(code-runtime-python): meter stdout and stderr stray residual against one shared budget

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').
This commit is contained in:
Chinesezjc
2026-08-31 14:22:37 +08:00
committed by Tianyi Cui
parent f29b4b1cb9
commit e76b3baf9e
5 changed files with 51 additions and 23 deletions
@@ -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')