mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-11 04:00:38 +00:00
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:
@@ -991,10 +991,22 @@ async def _pump_replies(
|
||||
continue
|
||||
|
||||
|
||||
# Non-string scalars only. The string form is scanned by hand in
|
||||
# :func:`_decode_json_plain` because a ``(?:[^"\\]|\\.)*`` repetition makes
|
||||
# CPython's backtracking engine retain per-repetition 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, so a legitimate multi-megabyte binding reply raised
|
||||
# MemoryError out of ``_pump_replies``, leaving its future unsettled until the
|
||||
# wall clock reported a timeout.
|
||||
_SCALAR_RE = re.compile(
|
||||
r'"(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null'
|
||||
r'-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null'
|
||||
)
|
||||
|
||||
# A run of ordinary string body characters. The star applies to a CHARACTER
|
||||
# CLASS, which the engine matches in one linear pass with no backtracking state,
|
||||
# so the scanner's cost is the number of escapes, not the string's width.
|
||||
_STRING_CHUNK_RE = re.compile(r'[^"\\]*')
|
||||
|
||||
|
||||
def _decode_json_plain(text: str) -> Any:
|
||||
"""Parse one JSON document iteratively (no per-level recursion).
|
||||
@@ -1017,7 +1029,26 @@ def _decode_json_plain(text: str) -> Any:
|
||||
i += 1
|
||||
return i
|
||||
|
||||
def scan_string(i: int) -> int:
|
||||
# Walk chunk by chunk: each match consumes every character up to the next
|
||||
# quote or backslash, so an escape costs one extra step and a plain body
|
||||
# costs one pass. Returns the offset just past the closing quote.
|
||||
j = i + 1
|
||||
while True:
|
||||
j = _STRING_CHUNK_RE.match(text, j).end()
|
||||
if j >= length:
|
||||
raise ValueError(f"unterminated string at offset {i}")
|
||||
char = text[j]
|
||||
if char == '"':
|
||||
return j + 1
|
||||
# text[j] is a backslash: skip it and the character it escapes. A
|
||||
# trailing backslash runs j past `length`, caught on the next pass.
|
||||
j += 2
|
||||
|
||||
def scalar(i: int):
|
||||
if i < length and text[i] == '"':
|
||||
end = scan_string(i)
|
||||
return json.loads(text[i:end]), end
|
||||
match = _SCALAR_RE.match(text, i)
|
||||
if match is None:
|
||||
raise ValueError(f"invalid JSON at offset {i}")
|
||||
@@ -1271,6 +1302,39 @@ for _escaped_byte, _surcharge in _JSON_ESCAPE_SURCHARGES:
|
||||
_JSON_BYTE_COST[_escaped_byte[0]] = 1 + _surcharge
|
||||
|
||||
|
||||
def _json_str_cost(text: str) -> int:
|
||||
"""Byte length of ``text``'s JSON string form, WITHOUT building that form.
|
||||
|
||||
The str-side twin of :func:`_json_string_cost`, for the completion-value
|
||||
meter. Measuring by materializing ``_dump_string(text).encode()`` allocates
|
||||
the escaped copy plus its encode -- for a NUL-heavy string that is ~6x the
|
||||
original each, so metering a value the budget would have REJECTED could
|
||||
itself breach ``RLIMIT_AS`` and report ``exception`` where the contract
|
||||
promises ``output-limit``.
|
||||
|
||||
The common case encodes once (~1x, well inside the load gate's envelope) and
|
||||
counts escapes with the same C-level passes :func:`_json_string_cost` uses.
|
||||
A string carrying surrogate code units has no UTF-8 form at all, so it takes
|
||||
the exact path :func:`_dump_string` defines: fold each spelled-out high-low
|
||||
pair into its astral character first (the host meters that as its raw 4-byte
|
||||
form), then charge six ASCII bytes for every surviving lone surrogate and
|
||||
count the rest from its encodable remainder.
|
||||
@param text: the string to measure.
|
||||
@return: the byte length of its JSON string form, quotes included.
|
||||
"""
|
||||
|
||||
try:
|
||||
return _json_string_cost(text.encode("utf-8"))
|
||||
except UnicodeEncodeError:
|
||||
pass
|
||||
folded = _SURROGATE_PAIR.sub(_combine_surrogate_pair, text)
|
||||
lone = len(_SURROGATE.findall(folded))
|
||||
# Six ASCII bytes per lone surrogate; the remainder is ordinary text whose
|
||||
# own quotes are dropped here because the outer call adds them once.
|
||||
without = _SURROGATE.sub("", folded)
|
||||
return _json_string_cost(without.encode("utf-8")) + lone * 6
|
||||
|
||||
|
||||
def _json_string_cost(raw: bytes) -> int:
|
||||
"""UTF-8 byte length of one string's JSON form, WITHOUT building that form.
|
||||
|
||||
@@ -1435,7 +1499,9 @@ def _check_done_value(value: Any, max_bytes: int):
|
||||
# The same string lower bound, before escaping the key.
|
||||
if total + len(key) + 3 > max_bytes:
|
||||
return over_budget
|
||||
total += len(_dump_scalar(key).encode("utf-8")) + 1
|
||||
# Same counting rule as the string branch: a control-heavy KEY
|
||||
# expands just as far, and `_dump_scalar` on a str is `_dump_string`.
|
||||
total += _json_str_cost(key) + 1
|
||||
if total > max_bytes:
|
||||
return over_budget
|
||||
stack.append(frame)
|
||||
@@ -1453,8 +1519,12 @@ def _check_done_value(value: Any, max_bytes: int):
|
||||
return over_budget
|
||||
# A lone surrogate has no UTF-8 form but a lossless JSON one — the
|
||||
# ASCII ``\uXXXX`` escape :func:`_dump_string` emits — so it is
|
||||
# metered, not rejected, matching the shared seam.
|
||||
total += len(_dump_string(current).encode("utf-8"))
|
||||
# metered, not rejected, matching the shared seam. Metered by
|
||||
# COUNTING, not by building the escaped form: that copy plus its
|
||||
# encode is ~6x the original for a control-heavy string, so measuring
|
||||
# a value the budget rejects could breach RLIMIT_AS and surface as
|
||||
# `exception` instead of the promised `output-limit`.
|
||||
total += _json_str_cost(current)
|
||||
elif type(current) is int:
|
||||
# The canonical boundary accepts every JS-double-exact value: an int
|
||||
# outside +-2**53-1 is fine IFF the double round-trip is exact.
|
||||
@@ -1539,13 +1609,41 @@ def _lossless_json_violation(value: Any) -> str | None:
|
||||
# ancestor (a cycle) is detected without rejecting a legitimately shared
|
||||
# acyclic subtree.
|
||||
on_path: set[int] = set()
|
||||
# Each frame is (value, is_leave): a leave frame pops its container off the path.
|
||||
stack: list[tuple[Any, bool]] = [(value, False)]
|
||||
# O(DEPTH) auxiliary space, not O(width), for the reason
|
||||
# :func:`_check_done_value` documents: this walk runs in ``dispatch`` on
|
||||
# MODEL-CONSTRUCTED binding arguments, which no child-side byte budget
|
||||
# bounds first (the frame ceiling is the host's, and it applies after this
|
||||
# returns). Enqueueing one frame per member would let a legitimate
|
||||
# ``[0] * 6_000_000`` argument -- ~17 MB of JSON -- allocate ~366 MB of
|
||||
# traversal tuples and die as the program's own MemoryError instead of
|
||||
# round-tripping. A container therefore pushes ONE cursor frame holding its
|
||||
# iterator; children are pulled one at a time.
|
||||
exhausted = object()
|
||||
visit, container_cursor = 0, 1
|
||||
# A visit frame is (visit, value); a cursor frame is (cursor, container, iterator).
|
||||
stack: list[tuple[int, Any, Any]] = [(visit, value, None)]
|
||||
while stack:
|
||||
current, is_leave = stack.pop()
|
||||
if is_leave:
|
||||
on_path.discard(id(current))
|
||||
kind = stack[-1][0]
|
||||
if kind == container_cursor:
|
||||
_, container, iterator = stack[-1]
|
||||
child = next(iterator, exhausted)
|
||||
if child is exhausted:
|
||||
# Leaving the container: it is no longer on the current path, so
|
||||
# a legitimately shared acyclic subtree is not mistaken for a cycle.
|
||||
on_path.discard(id(container))
|
||||
stack.pop()
|
||||
continue
|
||||
if type(container) is dict:
|
||||
# The dict cursor yields (key, value): check the key as it is
|
||||
# pulled. Only an EXACT str key survives -- int, float, None, and
|
||||
# tuple keys coerce or raise, and a str subclass can carry
|
||||
# overrides the encoder does not honor.
|
||||
key, child = child
|
||||
if type(key) is not str:
|
||||
return f"non-string dict key ({type(key).__name__})"
|
||||
stack.append((visit, child, None))
|
||||
continue
|
||||
_, current, _unused = stack.pop()
|
||||
if current is None or type(current) is bool:
|
||||
continue
|
||||
if type(current) is str:
|
||||
@@ -1579,17 +1677,14 @@ def _lossless_json_violation(value: Any) -> str | None:
|
||||
if id(current) in on_path:
|
||||
return "circular reference"
|
||||
on_path.add(id(current))
|
||||
stack.append((current, True))
|
||||
if type(current) is dict:
|
||||
for key in current:
|
||||
# Only an EXACT str key survives: int, float, None, and
|
||||
# tuple keys coerce or raise, and a str subclass can carry
|
||||
# overrides the encoder does not honor.
|
||||
if type(key) is not str:
|
||||
return f"non-string dict key ({type(key).__name__})"
|
||||
stack.extend((child, False) for child in current.values())
|
||||
# Keys are checked as the cursor pulls each entry, not in a
|
||||
# separate pass: ``current.values()`` would need a second walk,
|
||||
# and materializing ``items()`` up front allocates one tuple per
|
||||
# member -- the very spike the cursor removes.
|
||||
stack.append((container_cursor, current, iter(current.items())))
|
||||
else:
|
||||
stack.extend((child, False) for child in current)
|
||||
stack.append((container_cursor, current, iter(current)))
|
||||
continue
|
||||
return f"unsupported type ({type(current).__name__})"
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user