fix(code-runtime-python): raise the output-budget worst-case multiple to 12 and reject the boundary

The load-time output-budget/addressSpaceMb gate used a worst-case multiple of 8,
assuming two simultaneous ~4x astral copies (the built string and its encode).
Three are live at the peak: on the newline path a single write holds the caller's
text argument, the line slice handed to push, and push's encode copy; the
settlement flush_line path held the pending chunks, their join, and that encode
copy. A budget admitted at 8x (e.g. maxLogBytes 48 MiB against addressSpaceMb 512)
could still OOM the child. The multiple is now 12, the strict `>` is `>=` so a
budget whose peak exactly equals the room left after the interpreter baseline is
rejected (that peak plus the baseline is the whole address space), and flush_line
drops the pending chunks before its push to match the newline path's
join-clear-push order. The child re-check mirror and both note sides move in step;
config-catalog is regenerated from the updated field JSDoc.
This commit is contained in:
Chinesezjc
2026-08-31 14:22:37 +08:00
committed by Tianyi Cui
parent ce91c70f9a
commit 9d9525549d
7 changed files with 119 additions and 82 deletions
@@ -51,11 +51,13 @@ _MAX_FALLBACK_NAME_CHARS = 200
# Mirror of the host's output-budget/address-space gate (src/index.ts's
# OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE and INTERPRETER_BASELINE_BYTES),
# re-applied against the EFFECTIVE RLIMIT_AS after inheritance clamping. An astral
# character is one character but ~4 bytes of str storage and ~4 UTF-8 bytes, live
# at once while the ledger charges and frames it, so a budget's worst-case peak is
# eight times its byte count; the interpreter's own footprint is reserved on top.
# Kept in sync with the host constants by the shared reasoning, not a wire field.
_OUTPUT_BUDGET_WORST_CASE_MULTIPLE = 8
# character is one character but ~4 bytes of str storage and ~4 UTF-8 bytes, and
# three such copies are live at the peak — the caller's write argument, the line
# slice or joined pending handed to push, and the encode copy push takes — so a
# budget's worst-case peak is twelve times its byte count; the interpreter's own
# footprint is reserved on top. Kept in sync with the host constants by the shared
# reasoning, not a wire field.
_OUTPUT_BUDGET_WORST_CASE_MULTIPLE = 12
_INTERPRETER_BASELINE_BYTES = 64 * 1024 * 1024
@@ -350,9 +352,16 @@ class _LogStream(io.TextIOBase):
# against them.
with self._logs.lock:
if self._pending:
self._logs.push("".join(self._pending))
# Join, drop the chunks, THEN push — the same order the newline
# path uses (:232-235). Pushing before the clear would keep the
# pending chunks alive through `_push_locked`'s `text.encode`, so
# the chunks, their join, and the encode copy would all be live at
# once; dropping the chunks first leaves only the join and its
# encode, matching that path's peak.
line = "".join(self._pending)
self._pending = []
self._pending_chars = 0
self._logs.push(line)
# ---------------------------------------------------------------------------
@@ -664,7 +673,7 @@ async def _run(channel: ProtocolChannel) -> None:
if effective_soft != resource.RLIM_INFINITY:
budgetable = effective_soft - _INTERPRETER_BASELINE_BYTES
for _budget_key in ("maxLogBytes", "maxValueBytes"):
if int(boot[_budget_key]) * _OUTPUT_BUDGET_WORST_CASE_MULTIPLE > budgetable:
if int(boot[_budget_key]) * _OUTPUT_BUDGET_WORST_CASE_MULTIPLE >= budgetable:
raise ValueError(
"config.%s is too large for the inherited RLIMIT_AS of %d bytes "
"(a near-budget output would breach it during encode); "