fix(code-runtime-python): size the output-budget/address-space gate by worst-case Unicode and gate both budgets

The load-time addressSpaceMb gate used a 1/8 fraction derived for ASCII, but the
child ledgers trigger on character count against a serialized-byte budget: an
astral character is one character yet ~4 bytes stored and ~4 encoded, live at
once, so the true worst-case peak is ~8x the budget, not ~2x. Replace the
fraction with an explicit OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE (8)
and a strict `>`, and gate maxValueBytes the same way as maxLogBytes — the value
path builds and encodes a near-budget completion under the same RLIMIT_AS, so
the incompatible pair was previously admitted there too.

Slice the newline branch's unterminated tail to a budget-sized prefix: it
buffered the whole text[pos:] before the flush trigger could bound it, so an
early newline plus a huge tail made a second full copy of the model's string —
an RLIMIT_AS death the config gate cannot cover since the tail can far exceed
maxLogBytes.

Disclose the cross-field constraint in the maxLogBytes/maxValueBytes/addressSpaceMb
JSDoc (regenerating config-catalog); refresh the note's stale
Buffer.byteLength(JSON.stringify) reference; reconcile the arrival-order rebuttal
with the seam's "in order" logs JSDoc (within-stream, cross-stream best-effort).
Extend the load-rejection test to both budgets and add a tail-copy regression;
sync the zh pair.
This commit is contained in:
Chinesezjc
2026-08-31 14:22:37 +08:00
committed by Tianyi Cui
parent 2df88b5bbe
commit d9307ae2a4
7 changed files with 144 additions and 64 deletions
@@ -117,8 +117,11 @@ describe('PythonCodeRuntime — seam descriptors and misuse', () => {
await expect(ctx.plugin(PythonCodeRuntime, { maxValueBytes: admissible + 1 }))
.rejects.toThrow(/maxValueBytes must not exceed 268435392 .*fd-3 frame ceiling/)
// The boundary value itself loads: the bound is the largest cap a frame can
// still carry, not one below it.
const boundary = await ctx.plugin(PythonCodeRuntime, { maxValueBytes: admissible })
// still carry, not one below it. It needs an address space large enough to
// clear the separate maxValueBytes/addressSpaceMb worst-case gate (the cap
// times the 8x Unicode expansion must fit), so this pairs it with a 4 GiB
// addressSpaceMb — the two load-time bounds are independent.
const boundary = await ctx.plugin(PythonCodeRuntime, { maxValueBytes: admissible, addressSpaceMb: 4096 })
await boundary.dispose()
})
@@ -747,20 +750,25 @@ describe('PythonCodeRuntime — programs and bindings', () => {
expect(result.logs.at(-1)).toBe(logTruncationMarker(4096))
})
it('rejects a maxLogBytes that could breach addressSpaceMb during log encode at load', async () => {
// The child's log ledger encodes an admitted entry to UTF-8 once to charge
// its serialized cost, so a `maxLogBytes` approaching `addressSpaceMb` lets a
// legitimate near-budget log entry breach RLIMIT_AS and die as worker-exit
// instead of truncating. The incompatible pair is rejected at load rather
// than metered per-write at runtime: `maxLogBytes` must stay within one
// eighth of the `addressSpaceMb` byte count. 50 MB against a 64 MiB address
// space is far over that bound; the default 64 KiB against 512 MiB is not.
const ctx = new Context()
await expect(ctx.plugin(PythonCodeRuntime, { maxLogBytes: 50_000_000, addressSpaceMb: 64 }))
.rejects.toThrow(/maxLogBytes must not exceed .* of the .*addressSpaceMb/)
// A compatible pair loads.
it('rejects an output budget that could breach addressSpaceMb during encode at load', async () => {
// The child builds, charges, and encodes a `maxLogBytes` log entry or a
// `maxValueBytes` completion value under RLIMIT_AS, and both trigger on
// character count against a serialized-byte budget — an astral character is
// one character but ~4 bytes stored and ~4 encoded, so a budget approaching
// the address space lets a legitimate near-budget output breach it and die as
// worker-exit. The incompatible pair is rejected at load: each budget times
// the worst-case multiple (8) must fit the addressSpaceMb byte count. 50 MB
// against a 64 MiB address space is far over; the default caps against 512 MiB
// are not. Both budgets are gated symmetrically.
const ctxLog = new Context()
await expect(ctxLog.plugin(PythonCodeRuntime, { maxLogBytes: 50_000_000, addressSpaceMb: 64 }))
.rejects.toThrow(/maxLogBytes times the 8x worst-case Unicode expansion must fit/)
const ctxValue = new Context()
await expect(ctxValue.plugin(PythonCodeRuntime, { maxValueBytes: 50_000_000, addressSpaceMb: 64 }))
.rejects.toThrow(/maxValueBytes times the 8x worst-case Unicode expansion must fit/)
// The default caps against the default 512 MiB address space load.
const ok = new Context()
const fiber = await ok.plugin(PythonCodeRuntime, { maxLogBytes: 65536, addressSpaceMb: 512 })
const fiber = await ok.plugin(PythonCodeRuntime, { maxLogBytes: 65536, maxValueBytes: 32768, addressSpaceMb: 512 })
await fiber.dispose()
})
@@ -3571,6 +3579,29 @@ describe('PythonCodeRuntime — hostile peer', () => {
expect(result.logs.every(line => !line.includes(String.fromCharCode(0)))).toBe(true)
}, 30_000)
it('bounds a huge unterminated tail after an early newline without copying it whole', async () => {
// The newline branch of _LogStream.write buffered the whole unterminated
// tail after the last newline into `_pending` before the flush trigger could
// bound it, so an early newline followed by a huge tail made a second full
// copy of the model's own string — a MemoryError the config gate cannot
// catch (the tail far exceeds maxLogBytes). The tail is now sliced to a
// budget-sized prefix, so the run truncates and completes. Linux-only RLIMIT_AS
// repro (Darwin skips the limit); on macOS this asserts the happy path.
const { runtime } = await setup({ maxLogBytes: 256, addressSpaceMb: 384, maxWallMs: 20_000 })
const result = await runtime.run({
program: [
'import sys',
// A short first line, then a 200 MiB unterminated tail on the same write.
'sys.stdout.write("first\\n" + "A" * (200 * 1024 * 1024))',
'return "done"',
].join('\n'),
bindings: [],
})
expect(result.error).toBeUndefined()
expect(result.value).toBe('done')
expect(result.logs.some(line => line.includes('log capture truncated'))).toBe(true)
}, 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