fix(code-runtime-python): bound three child-side walks by depth, not width

Three separate paths in the CPython child allocated state proportional to a
value's width or a string's length, so a legitimate input the byte budgets
admit could die as the program's own MemoryError.

`_lossless_json_violation` enqueued one traversal tuple per member while
running, in `dispatch`, over MODEL-CONSTRUCTED binding arguments that no
child-side byte budget bounds first. It now uses the same (kind, container,
iterator) cursor the other two walks already had, checking dict keys as the
cursor pulls each entry. Measured over `[0] * 6_000_000` (~17 MB of JSON):
459.1 MiB of traversal tuples before, 0.0 MiB after.

`_decode_json_plain` matched JSON strings with a `(?:[^"\\]|\\.)*` repetition,
which makes CPython's engine retain backtracking state proportional to the
string's width: 146 MiB for a 1 MiB string, 557.8 MiB for 4 MiB. A legitimate
multi-megabyte binding reply raised MemoryError inside `_pump_replies`, and
because that pump is the only settler of the call's future, the run stranded
until the wall clock reported `timeout`. Strings now scan chunk-to-chunk over a
character class, which the engine matches without backtracking state; the same
4 MiB decode peaks at the 4.0 MiB result.

`_check_done_value` charged strings and dict keys what
`_dump_string(...).encode()` returned, building the escaped copy plus its
encode to MEASURE it -- ~6x the original each for control-heavy text, so
metering a value the budget then rejects could itself breach RLIMIT_AS and
report `exception` where the seam promises `output-limit`. The new
`_json_str_cost` counts instead, reusing `_json_string_cost`'s C-level passes
and reproducing `_dump_string`'s exact surrogate rules (fold spelled-out pairs,
charge six ASCII bytes per lone surrogate). Identical values, 228.9 MiB -> 19.1
MiB of peak on a 20M-NUL string.

Each fix ships a regression test. The two RLIMIT_AS repros are Linux-only:
Darwin does not apply the limit, so the peaks above are measured directly and
recorded in the test comments.
This commit is contained in:
Chinesezjc
2026-08-31 14:24:59 +08:00
committed by Tianyi Cui
parent 86674ed21e
commit 8f7d9121d1
3 changed files with 187 additions and 18 deletions
@@ -1079,6 +1079,28 @@ describe('PythonCodeRuntime — programs and bindings', () => {
expect(result.error?.message).toContain('exceeded 64 bytes')
})
it('meters a control-heavy completion value without materializing its escaped form', async () => {
// The child's lower bound admits a string by CHARACTER count, then the meter
// charged what `_dump_string(current).encode()` returned -- building the
// escaped copy plus its encode. Each NUL escapes to six bytes, so metering a
// value the budget then REJECTS allocated ~6x the original twice over:
// measured at 228.9 MiB of peak for a 20M-NUL string, against 19.1 MiB for
// the counting path that returns the identical 120,000,002 bytes. Past
// RLIMIT_AS the meter died as `exception: MemoryError`, inverting the
// `output-limit` this seam promises for an over-budget value.
//
// 8M NULs is 8,000,002 raw but 48,000,002 escaped: over the 16 MiB budget
// only when charged the escaped cost, so this also pins that the cheap
// character bound alone does not decide the verdict.
const { runtime } = await setup({ maxValueBytes: 16 * 1024 * 1024, maxWallMs: 60_000 })
const result = await runtime.run({
program: 'return "\\x00" * 8_000_000',
bindings: [],
})
expect(result.value).toBeUndefined()
expect(result.error?.kind).toBe('output-limit')
}, 90_000)
it('rejects a wide completion as output-limit before materializing its traversal state', async () => {
// `[0] * 2000000` sits far above maxValueBytes but well below the frame
// ceiling. The folded checker must reject it via the pre-enqueue bound —
@@ -3715,6 +3737,57 @@ describe('PythonCodeRuntime — hostile peer', () => {
expect((result.value as number[]).length).toBe(6_000_000)
}, 90_000)
it('validates wide binding arguments in O(depth), not O(width)', async () => {
// The completion-value walks are budgeted; this one is not. `dispatch` runs
// `_lossless_json_violation` on the arguments the MODEL built, and no
// child-side byte budget bounds them first: the frame ceiling is the host's
// and applies only after this validation returns. A per-member traversal
// frame therefore turned a legitimate call into the program's own
// MemoryError. Measured with tracemalloc on the two walk shapes over this
// exact argument (JSON ~17 MB): the cursor peaks at 0.0 MiB of auxiliary
// state, the pre-fix `stack.extend` at 459.1 MiB -- past the 384 MiB
// configured below, so the discriminating failure is real. It is Linux-only:
// Darwin skips RLIMIT_AS, so this case round-trips there either way.
//
// The binding echoes its argument's length back, so the assertion proves the
// call actually round-tripped rather than merely avoiding a crash.
const { runtime } = await setup({ addressSpaceMb: 384, maxWallMs: 60_000 })
const result = await runtime.run({
program: 'return await tools.width([0] * 6_000_000)',
bindings: [{
global: 'tools',
functions: { width: async (items: unknown) => (items as number[]).length },
}],
})
expect(result.error).toBeUndefined()
expect(result.value).toBe(6_000_000)
}, 90_000)
it('decodes a multi-megabyte binding reply without regex backtracking state', async () => {
// The child parses every host reply with `_decode_json_plain`. Its scalar
// regex matched strings with a `(?:[^"\\]|\\.)*` repetition, which makes
// CPython's backtracking engine retain state proportional to the string's
// WIDTH -- measured at ~146 MiB of engine state for a 1 MiB string and
// ~558 MiB for 4 MiB. A legitimate multi-megabyte reply therefore raised
// MemoryError inside `_pump_replies`; because that pump is the only settler
// of the call's future, the run stranded until the wall clock reported a
// `timeout` instead of returning the value the binding produced.
//
// Strings now scan chunk-to-chunk over a character class (no backtracking
// state). Measured on this exact 4 MiB reply: the pre-fix regex peaks at
// 557.8 MiB, past the default 512 MiB address space, while the scanner peaks
// at the 4.0 MiB result itself. Linux-only, like the other RLIMIT_AS repros:
// Darwin does not apply the limit, so the spike is merely allocated there.
const reply = 'A'.repeat(4 * 1024 * 1024)
const { runtime } = await setup({ maxWallMs: 60_000 })
const result = await runtime.run({
program: 'value = await tools.big({})\nreturn len(value)',
bindings: [{ global: 'tools', functions: { big: async () => reply } }],
})
expect(result.error).toBeUndefined()
expect(result.value).toBe(reply.length)
}, 90_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