fix(code-runtime-python): walk the completion value in O(depth), not O(width)

`_check_done_value` and `_encode_json_plain` pushed one stack entry per child
(plus a separator marker, and `dict.items()` materialized as a list), so the
bookkeeping scaled with the value's WIDTH rather than its depth. A value the
byte meter admits could then die on the walk's own frames: a flat
`[0] * 2_000_000` serializes to 4.0 MB, but measured peaks were 145.2 MB in the
meter and 114.7 MB in the encoder — 28.7x the serialized size, far past the 12x
the load-time address-space gate reserves.

Each container now pushes ONE cursor frame that pulls its children one at a
time and writes into a shared `io.StringIO`, so the output string is the only
width-proportional allocation and the caller already metered its size. Measured
on the same value: 0.0 MB in the meter and 9.0 MB in the encoder (2.3x), with
identical verdicts.
This commit is contained in:
Chinesezjc
2026-08-31 14:24:59 +08:00
committed by Tianyi Cui
parent 9a8663cc4c
commit bca73068f6
4 changed files with 131 additions and 61 deletions
@@ -3685,6 +3685,29 @@ describe('PythonCodeRuntime — hostile peer', () => {
expect(result.error?.kind).toBe('output-limit')
}, 30_000)
it('checks and encodes a wide completion value in O(depth), not O(width)', async () => {
// A wide flat list serializes to ~2 bytes per element but the pre-fix walk
// enqueued one traversal tuple per element (_check_done_value) and one stack
// entry plus a separator marker per element (_encode_json_plain) — ~56 bytes
// per element, ~28x the serialized size. A value the byte meter admits could
// therefore OOM on the checker's or encoder's own bookkeeping, the inversion
// the load gate exists to prevent (the gate reserves 12x, not 28x). Both now
// walk with an O(depth) cursor that pulls one child at a time, so the only
// width-proportional allocation is the output string the meter bounded.
//
// Config: maxValueBytes 20 MiB against 384 MiB (20*12 = 240 MiB < 320 MiB
// budgetable, so it loads). `[0] * 6_000_000` is ~12 MB of JSON, under the
// 20 MiB budget, so it must round-trip. Pre-fix the ~400 MB of per-element
// frames plus the interpreter exceeded 384 MiB and returned MemoryError as an
// exception. Linux-only RLIMIT_AS repro; on macOS the value round-trips
// either way, but the fixture stays within the address space so it is honest.
const { runtime } = await setup({ maxValueBytes: 20 * 1024 * 1024, addressSpaceMb: 384, maxWallMs: 20_000 })
const result = await runtime.run({ program: 'return [0] * 6_000_000', bindings: [] })
expect(result.error).toBeUndefined()
expect(Array.isArray(result.value)).toBe(true)
expect((result.value as number[]).length).toBe(6_000_000)
}, 30_000)
it('bounds a flood of zero-byte log lines through the per-entry separator charge', async () => {
// Blank print() lines carry zero content bytes; without the +1 separator
// charge they would bypass maxLogBytes entirely and grow the retained