fix(code-runtime-python): make the log seal incremental, scope the soft-lowering to RLIMIT_CPU, and capture memoryview

Addresses the bot's follow-up review findings on the settlement-path fixes:
- The _LogStream seal joined the WHOLE accumulated buffer past the fragment cap,
  re-copying the growing block O(B^2/cap) times for a large drip. It now seals
  only the current fragments into a _pending_blocks entry (character count
  unchanged), so a 25 M single-character drip stays O(B); the newline/flush/
  _push_bounded_prefix consumers join blocks + fragments once.
- The _clamped soft==hard lowering is scoped to RLIMIT_CPU: for RLIMIT_AS a
  one-byte soft differential would only misalign the child's applied limit with
  the host-side budget gate, with no signal to preserve. The hard == 1 blind
  spot is documented.
- send_done's fallback captures memoryview at import (_memoryview) alongside
  os.write, so a one-line rebind of the name cannot change the fallback write;
  the comment now states the module-level-captured mechanism.
This commit is contained in:
Chinesezjc
2026-08-31 14:33:31 +08:00
committed by Tianyi Cui
parent 72241b9f06
commit 9b29d0226e
@@ -41,12 +41,14 @@ from protocol import PROTOCOL_FD, log_truncation_marker # noqa: E402
# simply takes more reads. 64 KiB matches the usual pipe capacity.
_READ_CHUNK_BYTES = 65536
# Captured primitive for the done-frame LAST-resort fallback. This bootstrap IS
# Captured primitives for the done-frame LAST-resort fallback. This bootstrap IS
# ``__main__``, so ``import __main__; __main__.os = ...`` would rebind ``os.write``
# at call time inside ``ProtocolChannel.write_encoded``. ``os_write`` is a closure
# cell captured at import, before model code runs, so a one-line rebind cannot
# change which write the fallback uses. See ``send_done``'s try/except below.
# at call time inside ``ProtocolChannel.write_encoded``. ``_os_write`` and
# ``_memoryview`` are module-level names captured at import, before model code
# runs, so a one-line rebind cannot change which write the fallback uses. See
# ``send_done``'s try/except below.
_os_write = os.write
_memoryview = memoryview
# A fixed, pre-encoded done frame for the fallback. It carries no live model
# value, so it can always be written even when a transitive name (a ``_dump_*``
@@ -194,18 +196,26 @@ class _LogStream(io.TextIOBase):
# list-of-chunks, joined only at a newline or flush: repeated
# ``print("x", end="")`` must not concatenate quadratically.
self._pending: list[str] = []
self._pending_blocks: list[str] = []
self._pending_chars = 0
# A newline-free drip must not accumulate one list slot per ``write``:
# under a large ``maxLogBytes`` the list-of-fragments pointer array and
# the per-fragment str objects cost host memory well before the byte
# budget is reached, and a 25 M single-character drip would OOM on its
# own accounting (plus the same-size list ``_push_bounded_prefix`` then
# builds). Past this many fragments the chunks are sealed into one
# joined block (the character count is unchanged), bounding the live
# fragment count exactly as the host-side ``captureStray`` does with its
# ``MAX_PENDING_CHUNKS``.
# builds). Past this many fragments the chunks are SEALED into a
# ``_pending_blocks`` entry (the character count is unchanged), bounding
# the live fragment count exactly as the host-side ``captureStray`` does
# with its ``MAX_PENDING_CHUNKS``. The seal is INCREMENTAL: only the
# current ``_pending`` fragments (≤ cap) are joined into one block, not
# the whole accumulated buffer, so a large drip stays O(B) rather than
# re-copying the growing block O(B²/cap) times.
self._PENDING_MAX_CHUNKS = 1024
def _pending_parts(self) -> list[str]:
"""The sealed blocks followed by the current fragments, for joining."""
return [*self._pending_blocks, *self._pending]
def writable(self) -> bool: # noqa: D401 -- inherited contract
return True
@@ -243,7 +253,7 @@ class _LogStream(io.TextIOBase):
# timeout instead of the promised truncation marker).
length = len(text)
pos = 0
if self._pending:
if self._pending or self._pending_blocks:
newline = text.index("\n")
if self._pending_chars + newline + 3 > self._logs.remaining:
# The reconstructed first line cannot fit the ledger, so
@@ -257,8 +267,9 @@ class _LogStream(io.TextIOBase):
self._push_bounded_prefix(text[: min(newline, self._logs.remaining + 4)])
else:
self._pending.append(text[:newline])
line = "".join(self._pending)
line = "".join(self._pending_parts())
self._pending = []
self._pending_blocks = []
self._pending_chars = 0
self._logs.push(line)
pos = newline + 1
@@ -322,14 +333,16 @@ class _LogStream(io.TextIOBase):
# appends one fragment per write, so a 25 M single-character flood
# would accumulate that many list slots (and str objects) long before
# the byte budget is met — the pointer array alone being ~25 M slots.
# Joining the fragments into one block keeps the SAME character count
# (`_pending_chars` is unchanged) while bounding the live fragment
# count, mirroring the host-side `captureStray` seal. The join is
# only as large as the buffered characters, which the budget already
# bounds; the fragments are otherwise un-sealable mid-newline because
# a newline never starts a multi-byte sequence.
# Past the cap the current fragments are joined into ONE block and
# moved to `_pending_blocks` (character count unchanged), bounding the
# live fragment count exactly as the host-side `captureStray` seal
# does. The join is only the ≤cap current fragments, never the whole
# accumulated buffer, so a large drip stays O(B) rather than
# re-copying the growing block O(B²/cap) times; a newline never starts
# a multi-byte sequence, so the fragments are un-sealable mid-line.
if len(self._pending) >= self._PENDING_MAX_CHUNKS:
self._pending = ["".join(self._pending)]
self._pending_blocks.append("".join(self._pending))
self._pending = []
# A newline-free flood must hit the budget while running, not at
# settlement: once the buffered tail alone can no longer fit the
# ledger (chars lower-bound the serialized cost), push it through — LogBuffer
@@ -367,7 +380,7 @@ class _LogStream(io.TextIOBase):
limit = self._logs.remaining + 4
parts: list[str] = []
total = 0
for chunk in self._pending:
for chunk in self._pending_parts():
parts.append(chunk[: limit - total])
total += len(parts[-1])
if total >= limit:
@@ -377,6 +390,7 @@ class _LogStream(io.TextIOBase):
# `extra` is the one remaining source of text.
parts.append(extra[: limit - total])
self._pending = []
self._pending_blocks = []
self._pending_chars = 0
self._logs.push("".join(parts))
@@ -400,15 +414,16 @@ class _LogStream(io.TextIOBase):
# ``_pending`` and the ledger, so this read-and-clear must be atomic
# against them.
with self._logs.lock:
if self._pending:
if self._pending or self._pending_blocks:
# Join, drop the chunks, THEN push — the same join-clear-push order
# as `_write_locked`'s newline branch. 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)
line = "".join(self._pending_parts())
self._pending = []
self._pending_blocks = []
self._pending_chars = 0
self._logs.push(line)
@@ -680,17 +695,25 @@ def _clamped(which: int, soft: int, hard: int) -> tuple[int, int]:
# soft under hard as the final step; the stricter hard ceiling wins.
result_soft = min(clamped_soft, clamped_hard)
result_hard = clamped_hard
# A soft limit EQUAL to the hard limit leaves the kernel no window to send
# SIGXCPU: it checks the hard limit in the same tick and SIGKILLs directly
# (a `ulimit -t N` sets both, and a busy loop then dies by SIGKILL, not
# SIGXCPU). The host classifies a CPU overrun ONLY on ``signal ===
# 'SIGXCPU'``, so a definite budget exhaustion would be misreported as a
# For RLIMIT_CPU, a soft limit EQUAL to the hard limit leaves the kernel no
# window to send SIGXCPU: it checks the hard limit in the same tick and
# SIGKILLs directly (a `ulimit -t N` sets both, and a busy loop then dies by
# SIGKILL, not SIGXCPU). The host classifies a CPU overrun ONLY on ``signal
# === 'SIGXCPU'``, so a definite budget exhaustion would be misreported as a
# `worker-exit`. Lowering the soft limit one unit below the hard (when the
# hard is at least 2, so soft stays positive) keeps the stricter-of-the-two
# containment semantics while giving SIGXCPU a window to fire — the CPU
# overrun is then reported as a timeout, not a worker-exit. For RLIMIT_AS
# this is one byte stricter, harmless.
if result_soft == result_hard and result_hard >= 2:
# overrun is then reported as a timeout, not a worker-exit. This is scoped to
# RLIMIT_CPU: for RLIMIT_AS a one-byte soft differential would only misalign
# the child's applied limit with the host-side budget gate, with no signal to
# preserve. The ``hard >= 2`` guard leaves the ``hard == 1`` blind spot
# (a 1-second dual limit cannot lower soft to 0) — a definite CPU overrun
# there is still reported as `worker-exit`; see the settlement note.
if (
which == resource.RLIMIT_CPU
and result_soft == result_hard
and result_hard >= 2
):
result_soft = result_hard - 1
return (result_soft, result_hard)
@@ -979,12 +1002,14 @@ async def _run(channel: ProtocolChannel) -> None:
# `__main__.os = ...`) makes the error-frame encode/write throw AFTER
# the `except` block, which would drop the `done` frame and downgrade a
# settled `exception` verdict to a host-side `worker-exit`. Write a fixed
# literal done frame with the captured `_os_write` (itself immune to a
# rebind) so the host still gets a verdict. The literal is JSON-valid
# and newline-terminated; the lock is the channel's, so the write is
# serialized against any concurrent writer.
# literal done frame with the import-time captured `_os_write` and
# `_memoryview` (module-level names captured before model code runs, so
# a one-line rebind cannot change them) so the host still gets a
# verdict. The literal is JSON-valid and newline-terminated; the lock
# is the channel's, so the write is serialized against any concurrent
# writer.
with channel._write_lock:
view = memoryview(_FALLBACK_DONE_FRAME)
view = _memoryview(_FALLBACK_DONE_FRAME)
while view:
view = view[_os_write(channel._fd, view):]