fix(code-runtime-python): close the child stdin handle and def-time capture the frame decode primitives

Addresses the review's two remaining items:
- The host closes the child's stdin write handle immediately after spawn. The
  program is an async body that reads nothing from fd 0; a live pipe would hold
  a host-side handle open past the run, so a setsid-escaped descendant
  inheriting fd 0 could keep the host process from exiting even after the
  closeDeadline forced settlement. The child (and any descendant) reads EOF on
  fd 0 and no host handle survives.
- read_frame/read_frame_async bind their decode primitives (_decode_json_plain,
  os.read, _READ_CHUNK_BYTES, bytes) as def-time default arguments, and
  _decode_json_plain itself captures json.loads, its two regexes, and len the
  same way, so a __main__ rebind cannot kill the reply pump and strand every
  pending Future to the wall clock. _decode_json_plain and its regexes moved
  before the ProtocolChannel class so the defaults resolve at class-definition
  time. A regression test rebinds _decode_json_plain and asserts a binding reply
  still round-trips.
Note (en + zh) registers both mechanisms; pairings re-recorded.
This commit is contained in:
Chinesezjc
2026-08-31 14:47:57 +08:00
committed by Tianyi Cui
parent ac64039843
commit 40fbf92290
6 changed files with 204 additions and 147 deletions
@@ -445,6 +445,152 @@ class _LogStream(io.TextIOBase):
# ---------------------------------------------------------------------------
# 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'
)
# 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,
# Def-time captures: the decoder runs AFTER the program (which is `__main__`)
# may have rebound `__main__.json`, `__main__._SCALAR_RE`,
# `__main__._STRING_CHUNK_RE`, or `__main__.len`; a call-time lookup would
# let a one-line rebind kill the reply pump (a broken decode strands every
# pending Future to the wall clock). Defaults are evaluated at def time.
_json_loads: Any = json.loads,
_scalar_re: Any = _SCALAR_RE,
_string_chunk_re: Any = _STRING_CHUNK_RE,
_len: Any = len,
) -> Any:
"""Parse one JSON document iteratively (no per-level recursion).
``json.loads`` recurses per nesting level and raises ``RecursionError``
around ~10k levels, but a binding reply is depth-unbounded by the seam
contract — the host's iterative encoder happily produces documents
``json.loads`` cannot read back. Scalars (numbers, strings with escapes)
are delegated to ``json.loads`` one token at a time, so their grammar and
semantics stay CPython's own; only the container structure is parsed here
with an explicit stack. Raises ``ValueError`` on malformed input; frames
come from the TRUSTED host, so strictness mirrors ``json.loads`` without
extra hostile-input hardening.
"""
length = _len(text)
def skip_ws(i: int) -> int:
while i < length and text[i] in " \t\n\r":
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}")
return _json_loads(match.group(0)), match.end()
def string_key(i: int):
key, end = scalar(i)
if not isinstance(key, str):
raise ValueError(f"object key must be a string at offset {i}")
end = skip_ws(end)
if end >= length or text[end] != ":":
raise ValueError(f"expected ':' at offset {end}")
return key, skip_ws(end + 1)
# Frames: a list, or (dict, pending key). `value`/`have_value` carry each
# completed value up to its parent frame.
stack: list[Any] = []
value: Any = None
have_value = False
i = skip_ws(0)
while True:
if not have_value:
ch = text[i] if i < length else ""
if ch == "[":
i = skip_ws(i + 1)
if i < length and text[i] == "]":
i += 1
value, have_value = [], True
else:
stack.append([])
continue
elif ch == "{":
i = skip_ws(i + 1)
if i < length and text[i] == "}":
i += 1
value, have_value = {}, True
else:
key, i = string_key(i)
stack.append(({}, key))
continue
else:
value, i = scalar(i)
have_value = True
if not stack:
i = skip_ws(i)
if i != length:
raise ValueError(f"trailing data at offset {i}")
return value
top = stack[-1]
i = skip_ws(i)
ch = text[i] if i < length else ""
if isinstance(top, list):
top.append(value)
if ch == ",":
i = skip_ws(i + 1)
have_value = False
elif ch == "]":
i += 1
stack.pop()
value = top
else:
raise ValueError(f"expected ',' or ']' at offset {i}")
else:
container, key = top
container[key] = value
if ch == ",":
key, i = string_key(skip_ws(i + 1))
stack[-1] = (container, key)
have_value = False
elif ch == "}":
i += 1
stack.pop()
value = container
else:
raise ValueError(f"expected ',' or '}}' at offset {i}")
class ProtocolChannel:
"""Blocking readers and synchronous writers over the fd-3 protocol pipe.
@@ -472,7 +618,17 @@ class ProtocolChannel:
# completion frame drains could interleave bytes mid-frame.
self._write_lock = threading.Lock()
def read_frame(self) -> dict[str, Any] | None:
def read_frame(
self,
# Def-time captures for the decode primitives (see _decode_json_plain):
# this runs before the program, but the reply path does not — a rebind
# of `__main__._decode_json_plain`/`__main__.os`/`__main__._READ_CHUNK_BYTES`
# must not break the pump.
_decode: Any = _decode_json_plain,
_os_read: Any = os.read,
_read_chunk: int = _READ_CHUNK_BYTES,
_bytes: Any = bytes,
) -> dict[str, Any] | None:
"""Read one JSON-line frame (iteratively decoded). ``None`` on EOF.
Blocking. Used for the two frames read BEFORE the model program starts
@@ -496,18 +652,25 @@ class ProtocolChannel:
while True:
newline = self._pending.find(b"\n", scanned)
if newline >= 0:
line = bytes(self._pending[:newline])
line = _bytes(self._pending[:newline])
del self._pending[: newline + 1]
return _decode_json_plain(line.decode("utf-8"))
return _decode(line.decode("utf-8"))
scanned = len(self._pending)
chunk = os.read(self._fd, _READ_CHUNK_BYTES)
chunk = _os_read(self._fd, _read_chunk)
if not chunk:
# EOF before a newline: drop the partial line, as the host drops
# a frame that never completed.
return None
self._pending.extend(chunk)
async def read_frame_async(self) -> dict[str, Any] | None:
async def read_frame_async(
self,
# Def-time captures, same rationale as read_frame.
_decode: Any = _decode_json_plain,
_os_read: Any = os.read,
_read_chunk: int = _READ_CHUNK_BYTES,
_bytes: Any = bytes,
) -> dict[str, Any] | None:
"""Await one JSON-line frame without occupying a thread. ``None`` on EOF.
``loop.run_in_executor(None, read_frame)`` was the obvious spelling and
@@ -538,9 +701,9 @@ class ProtocolChannel:
while True:
newline = self._pending.find(b"\n", scanned)
if newline >= 0:
line = bytes(self._pending[:newline])
line = _bytes(self._pending[:newline])
del self._pending[: newline + 1]
return _decode_json_plain(line.decode("utf-8"))
return _decode(line.decode("utf-8"))
scanned = len(self._pending)
ready = loop.create_future()
# `add_reader` only reports readability; the read itself happens here,
@@ -550,7 +713,7 @@ class ProtocolChannel:
await ready
finally:
loop.remove_reader(self._fd)
chunk = os.read(self._fd, _READ_CHUNK_BYTES)
chunk = _os_read(self._fd, _read_chunk)
if not chunk:
# EOF. Any partial line is dropped, matching how the host drops a
# frame that never completed.
@@ -1240,141 +1403,6 @@ 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'
)
# 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).
``json.loads`` recurses per nesting level and raises ``RecursionError``
around ~10k levels, but a binding reply is depth-unbounded by the seam
contract — the host's iterative encoder happily produces documents
``json.loads`` cannot read back. Scalars (numbers, strings with escapes)
are delegated to ``json.loads`` one token at a time, so their grammar and
semantics stay CPython's own; only the container structure is parsed here
with an explicit stack. Raises ``ValueError`` on malformed input; frames
come from the TRUSTED host, so strictness mirrors ``json.loads`` without
extra hostile-input hardening.
"""
length = len(text)
def skip_ws(i: int) -> int:
while i < length and text[i] in " \t\n\r":
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}")
return json.loads(match.group(0)), match.end()
def string_key(i: int):
key, end = scalar(i)
if not isinstance(key, str):
raise ValueError(f"object key must be a string at offset {i}")
end = skip_ws(end)
if end >= length or text[end] != ":":
raise ValueError(f"expected ':' at offset {end}")
return key, skip_ws(end + 1)
# Frames: a list, or (dict, pending key). `value`/`have_value` carry each
# completed value up to its parent frame.
stack: list[Any] = []
value: Any = None
have_value = False
i = skip_ws(0)
while True:
if not have_value:
ch = text[i] if i < length else ""
if ch == "[":
i = skip_ws(i + 1)
if i < length and text[i] == "]":
i += 1
value, have_value = [], True
else:
stack.append([])
continue
elif ch == "{":
i = skip_ws(i + 1)
if i < length and text[i] == "}":
i += 1
value, have_value = {}, True
else:
key, i = string_key(i)
stack.append(({}, key))
continue
else:
value, i = scalar(i)
have_value = True
if not stack:
i = skip_ws(i)
if i != length:
raise ValueError(f"trailing data at offset {i}")
return value
top = stack[-1]
i = skip_ws(i)
ch = text[i] if i < length else ""
if isinstance(top, list):
top.append(value)
if ch == ",":
i = skip_ws(i + 1)
have_value = False
elif ch == "]":
i += 1
stack.pop()
value = top
else:
raise ValueError(f"expected ',' or ']' at offset {i}")
else:
container, key = top
container[key] = value
if ch == ",":
key, i = string_key(skip_ws(i + 1))
stack[-1] = (container, key)
have_value = False
elif ch == "}":
i += 1
stack.pop()
value = container
else:
raise ValueError(f"expected ',' or '}}' at offset {i}")
def _encode_json_plain(value: Any) -> str:
"""Encode JSON-plain data iteratively, byte-identical to compact ``json.dumps``.