bootstrap.py 133 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541
  1. """CPython bootstrap for dsh-code-runtime-python.
  2. Reads a :class:`BootMessage` on fd 3, applies resource limits and log capture,
  3. reads a :class:`RunMessage`, runs the model program as the body of an async
  4. function (top-level ``await`` and ``return`` both work; the returned value is
  5. the completion), and posts a terminal :class:`DoneMessage`. The program calls
  6. host functions through the ``tools`` (or other namespace) proxy, whose attribute
  7. and subscript access return awaitables that ride binding messages over fd 3.
  8. This module runs under ``python3 -I`` with only ``TMPDIR`` in its environment
  9. and ``sys.path`` containing only its own directory.
  10. """
  11. from __future__ import annotations
  12. import asyncio
  13. import ast
  14. import io
  15. import json
  16. import math
  17. import os
  18. import re
  19. import resource
  20. import signal
  21. import sys
  22. import threading
  23. import traceback
  24. from decimal import Context, Decimal
  25. # The float encoder must NOT depend on the process-global decimal context: a
  26. # legitimate program may set `getcontext().prec = 2` (silently rounding the
  27. # completion value's digits) or `traps[Inexact] = True` (making the encode
  28. # raise, misclassifying a successful run as an exception). A fixed context with
  29. # prec=28 (more than the 17 significant digits a double needs) makes the
  30. # normalize() spelling decision context-independent.
  31. _FLOAT_CONTEXT = Context(prec=28)
  32. from pathlib import Path
  33. from typing import Any
  34. # ``python3 -I`` (isolated) drops the script directory from ``sys.path`` so
  35. # the sibling ``protocol.py`` is invisible by default. Restore it explicitly
  36. # before importing.
  37. sys.path.insert(0, str(Path(__file__).resolve().parent))
  38. from protocol import PROTOCOL_FD, log_truncation_marker # noqa: E402
  39. # Read size for the async fd-3 reader. One `os.read` returns whatever the pipe
  40. # holds, so this only bounds a single syscall's copy, not a frame: a larger frame
  41. # simply takes more reads. 64 KiB matches the usual pipe capacity.
  42. _READ_CHUNK_BYTES = 65536
  43. # Module-level captures for the done-frame LAST-resort fallback. This bootstrap IS
  44. # ``__main__``, so ``import __main__; __main__.os = ...`` would rebind ``os.write``
  45. # at call time inside ``ProtocolChannel.write_encoded``. These module-level names
  46. # are the RAW primitive for the fallback; they are BOUND INTO ``_run`` LOCALS
  47. # before the program runs (see ``send_done``), which is what makes a one-line
  48. # rebind unable to change which write the fallback uses — the module global here
  49. # is itself reachable by ``__main__._os_write = boom``, so the immunity lives in
  50. # the ``_run`` frame-local binding, not in the module global.
  51. _os_write = os.write
  52. _memoryview = memoryview
  53. # A fixed, pre-encoded done frame for the fallback. It carries no live model
  54. # value, so it can always be written even when a transitive name (a ``_dump_*``
  55. # helper or ``os``) has been rebound and the normal encode/write threw. The
  56. # message is the fixed literal ``<unrenderable>`` — distinct from the failure
  57. # reporter's ``_UNRENDERABLE_DIAGNOSTIC`` text; the host renders the run as an
  58. # exception rather than a worker-exit, which is the honest verdict for a settled
  59. # run whose reporting was sabotaged. The bytes are JSON-valid and
  60. # newline-terminated.
  61. _FALLBACK_DONE_FRAME = b'{"type":"done","error":{"kind":"exception","message":"<unrenderable>"}}\n'
  62. # Code-unit ceiling on the exception class name interpolated into the LAST-resort
  63. # failure diagnostic. A metaclass `__name__` property can return any length, and
  64. # that construction runs outside the guard that would otherwise absorb a
  65. # MemoryError, so the name is sliced before it is copied. Generous enough that no
  66. # real class name is touched.
  67. _MAX_FALLBACK_NAME_CHARS = 200
  68. # Mirror of the host's output-budget/address-space gate (src/index.ts's
  69. # OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE and INTERPRETER_BASELINE_BYTES),
  70. # re-applied against the EFFECTIVE RLIMIT_AS after inheritance clamping. An astral
  71. # character is one character but ~4 bytes of str storage and ~4 UTF-8 bytes, and
  72. # three such copies are live at the peak — the caller's write argument, the line
  73. # slice or joined pending handed to push, and the encode copy push takes — so a
  74. # budget's worst-case peak is twelve times its byte count; the interpreter's own
  75. # footprint is reserved on top. Kept in sync with the host constants by the shared
  76. # reasoning, not a wire field.
  77. _OUTPUT_BUDGET_WORST_CASE_MULTIPLE = 12
  78. _INTERPRETER_BASELINE_BYTES = 64 * 1024 * 1024
  79. # ---------------------------------------------------------------------------
  80. # Log buffer — Python-side ledger for captured text.
  81. # ---------------------------------------------------------------------------
  82. class LogBuffer:
  83. """Ordered text capture under one shared byte budget.
  84. Once the budget is exhausted the buffer emits exactly one in-band
  85. truncation marker via ``sink`` and silently drops everything after. The
  86. cap is a blast-radius bound; "how much was lost" intentionally stays
  87. unmeasured.
  88. """
  89. def __init__(self, max_bytes: int, sink) -> None:
  90. self._max_bytes = max_bytes
  91. # The ledger starts one byte below max_bytes: each entry is charged its
  92. # JSON-string cost plus one separator byte, and the serialized outer
  93. # logs array adds one more byte of envelope (two brackets and n-1 commas
  94. # over n entries' separators), so a result that exactly exhausts the
  95. # ledger serializes to exactly max_bytes; WITHOUT the reserved byte it
  96. # would serialize to max_bytes + 1. Reserving that byte keeps an
  97. # admitted result within the configured cap; the truncation-marker entry
  98. # is envelope, not payload, and rides uncharged (``_max_bytes`` stays the
  99. # configured value for the marker's message text).
  100. self._remaining = max_bytes - 1
  101. self._truncated = False
  102. # True while an `open` (unterminated-flush) entry is being accumulated:
  103. # continuation fragments bill only their CONTENT (no quotes — they ride
  104. # on the first fragment — and no separator), so a merged entry's wire
  105. # cost is billed exactly once, split across its fragments, matching the
  106. # host ledger.
  107. self._open_started = False
  108. # Re-entrant so a caller may hold it across a compound read-modify-write
  109. # (``_LogStream.write`` reads ``remaining`` several times and then calls
  110. # ``push`` while still holding it). One lock is shared by this buffer and
  111. # every stream that funnels into it: model code may start daemon threads
  112. # that keep calling ``print`` after the program body returns, and the
  113. # settlement ``flush_line`` on the main coroutine reads and mutates the
  114. # same ``_pending``/ledger state. Without a shared lock the flush could
  115. # interleave with a concurrent ``write`` — dropping or double-counting a
  116. # line, or costing the ``done`` frame on a mangled ledger. Fixing which
  117. # callable runs (binding ``out_stream.flush_line``) does not fix what it
  118. # reads.
  119. self._lock = threading.RLock()
  120. # ``sink(text, truncated=False)``. The marker is emitted with
  121. # ``truncated=True`` so the host can stop its own capture at the same
  122. # point rather than treating the marker as ordinary program output: the
  123. # two ledgers exhaust independently, and one entry larger than
  124. # ``max_bytes`` sends only the marker while the host budget is still
  125. # nearly empty.
  126. self._sink = sink
  127. @property
  128. def lock(self) -> "threading.RLock":
  129. """The shared re-entrant lock guarding this ledger and its streams' buffers."""
  130. return self._lock
  131. @property
  132. def remaining(self) -> int:
  133. """Serialized bytes still admissible; zero once truncated (streams use this to bound their own buffering, where a character count is a valid lower bound)."""
  134. return 0 if self._truncated else self._remaining
  135. def push(self, text: str, open: bool = False) -> None:
  136. with self._lock:
  137. self._push_locked(text, open)
  138. def _push_locked(self, text: str, open: bool = False) -> None:
  139. if self._truncated:
  140. return
  141. # Cheap lower bound FIRST: one char is at least one UTF-8 byte and the
  142. # JSON form adds two quotes plus the separator, so a single print() far
  143. # above the budget truncates without ever encoding it — the full encode
  144. # would allocate a second equally large string and could turn a
  145. # truncatable log into an RLIMIT_AS death.
  146. if (len(text) + 3 if not self._open_started else len(text)) > self._remaining:
  147. self._truncated = True
  148. self._sink(log_truncation_marker(self._max_bytes), truncated=True)
  149. return
  150. # A model print() can emit a lone surrogate; strict UTF-8 throws on it
  151. # here. Replace it rather than escaping it the way :func:`_dump_string`
  152. # preserves one inside a completion VALUE: log text is already a
  153. # truncatable, substituting channel (the byte cap replaces the tail with
  154. # a marker), and the ledger below charges the RAW UTF-8 bytes, which
  155. # would undercharge the six-byte escape by half. Bounded: the text
  156. # passed the length check, so this encodes at most ~4x remaining.
  157. try:
  158. raw = text.encode("utf-8")
  159. except UnicodeEncodeError:
  160. raw = text.encode("utf-8", errors="replace")
  161. text = raw.decode("utf-8")
  162. # Charge the SERIALIZED cost — the JSON string form's bytes plus one
  163. # separator byte — exactly as the host ledger does. Charging the raw
  164. # UTF-8 length instead undercharges control-heavy text, whose JSON
  165. # escaping expands it up to sixfold (a NUL costs one raw byte but six
  166. # as its ``\uXXXX`` escape): a NUL flood sized to fit ``maxLogBytes``
  167. # raw would serialize to roughly six times the shared cap, and the child
  168. # could then die on RLIMIT_AS (reported host-side as ``worker-exit``)
  169. # instead of emitting the truncation marker. The +1 also floors an empty
  170. # entry above zero, so a flood of blank ``print()`` lines exhausts the
  171. # budget instead of emitting unbounded zero-cost log frames.
  172. # Split billing for a merged entry: the FIRST fragment pays the full
  173. # JSON-string cost plus the separator; every later fragment — a
  174. # continuation OR the closing frame (it is the merged entry's tail, not
  175. # a new entry) — pays only its content, since the quotes and separator
  176. # were billed on the first fragment. A standalone closed entry (no open
  177. # in progress) pays the full cost as before.
  178. if self._open_started:
  179. cost = _json_string_cost(raw) - 2
  180. if cost < 0:
  181. cost = 0
  182. else:
  183. cost = _json_string_cost(raw) + 1
  184. if cost > self._remaining:
  185. self._truncated = True
  186. self._sink(log_truncation_marker(self._max_bytes), truncated=True)
  187. return
  188. self._remaining -= cost
  189. if open:
  190. self._open_started = True
  191. else:
  192. self._open_started = False
  193. self._sink(text, open=open)
  194. class _LogStream(io.TextIOBase):
  195. """A newline-coalescing text stream backed by a :class:`LogBuffer`.
  196. Installed as ``sys.stdout`` / ``sys.stderr`` before executing the model
  197. program. ``print(...)`` calls ``write`` once per argument, separator, and
  198. newline, so a raw one-push-per-write stream would emit
  199. ``["a", " ", "b", "\\n"]`` for ``print("a", "b")`` — and PTC mode renders
  200. ``logs`` with ``join('\\n')``, turning that into spurious blank lines. This
  201. stream instead buffers writes and pushes one LogBuffer entry per completed
  202. LINE (the text up to each ``\\n``, newline stripped), so the rendered join
  203. reproduces ``a b``. Any unterminated tail is flushed by :meth:`flush_line`
  204. after the program settles.
  205. """
  206. def __init__(self, logs: LogBuffer) -> None:
  207. super().__init__()
  208. self._logs = logs
  209. # list-of-chunks, joined only at a newline or flush: repeated
  210. # ``print("x", end="")`` must not concatenate quadratically.
  211. self._pending: list[str] = []
  212. self._pending_blocks: list[str] = []
  213. self._pending_chars = 0
  214. # A newline-free drip must not accumulate one list slot per ``write``:
  215. # under a large ``maxLogBytes`` the list-of-fragments pointer array and
  216. # the per-fragment str objects cost host memory well before the byte
  217. # budget is reached, and a 25 M single-character drip would OOM on its
  218. # own accounting (plus the same-size list ``_push_bounded_prefix`` then
  219. # builds). Past this many fragments the chunks are SEALED into a
  220. # ``_pending_blocks`` entry (the character count is unchanged), bounding
  221. # the live fragment count exactly as the host-side ``captureStray`` does
  222. # with its ``MAX_PENDING_CHUNKS``. The seal is INCREMENTAL: only the
  223. # current ``_pending`` fragments (≤ cap) are joined into one block, not
  224. # the whole accumulated buffer, so a large drip stays O(B) rather than
  225. # re-copying the growing block O(B²/cap) times.
  226. self._PENDING_MAX_CHUNKS = 1024
  227. def _pending_parts(self) -> list[str]:
  228. """The sealed blocks followed by the current fragments, for joining."""
  229. return [*self._pending_blocks, *self._pending]
  230. def writable(self) -> bool: # noqa: D401 -- inherited contract
  231. return True
  232. def write(self, text: str) -> int: # noqa: D401 -- inherited contract
  233. # Serialize the whole read-modify-write against the settlement flush and
  234. # any other thread's write: model code may spawn daemon threads that keep
  235. # printing after the program body returns, and this method reads
  236. # ``remaining`` and mutates ``_pending``/the ledger across many steps. The
  237. # lock is the buffer's and is re-entrant, so the ``push`` calls below
  238. # (which re-acquire it) do not deadlock.
  239. with self._logs.lock:
  240. return self._write_locked(text)
  241. def _write_locked(self, text: str) -> int:
  242. # Drop an empty write instead of buffering it. An empty chunk adds no
  243. # character, so the budget check below can never fire on it:
  244. # ``while True: sys.stdout.write("")`` would append one list slot per
  245. # call with `_pending_chars` pinned at 0, growing unbounded long after
  246. # the log ledger was exhausted (about 3.7 M slots per CPU second here)
  247. # until RLIMIT_AS turned the allocation into a MemoryError — reported as
  248. # the program's own exception rather than the intended bounded-log
  249. # behavior. Returning here also keeps `flush_line` from pushing a
  250. # spurious empty log entry for a program whose only writes were empty.
  251. if not text:
  252. return 0
  253. if "\n" in text:
  254. # Scan `text` in place; the buffered chunks are joined ONLY into the
  255. # first line. Joining the pending chunks with the whole write first
  256. # made a second copy of that write, which an over-budget write
  257. # cannot afford (measured under a 400 MiB addressSpaceMb: one
  258. # buffered character followed by a 340 MiB write died on MemoryError
  259. # inside the join, reported as the program's own exception, and the
  260. # retained chunks made the settlement `flush_line` fail the same way
  261. # — costing the `done` frame and turning the run into a wall-clock
  262. # timeout instead of the promised truncation marker).
  263. length = len(text)
  264. pos = 0
  265. if self._pending or self._pending_blocks:
  266. newline = text.index("\n")
  267. # The +3 cheap-bound overhead (quotes + separator) belongs to a
  268. # NEW entry. While an `open` entry is accumulating, the closing
  269. # line is that entry's TAIL: its cheap bound is the content
  270. # length alone, matching `_push_locked`'s open-aware bound.
  271. # This bound's observable behavior is invariant under the
  272. # overhead either way: when the +3 form trips and the open-aware
  273. # form does not (pending + newline in [remaining - 2, remaining]),
  274. # _push_bounded_prefix re-slices the SAME line text (the extra
  275. # slice `text[:newline]` carries no newline) and _push_locked
  276. # admits it under the same open-aware billing, byte for byte.
  277. # The open-aware form only keeps _push_bounded_prefix's "certain
  278. # to reject" precondition true, which is exactly what the scan
  279. # pre-check below does NOT preserve (its slice carries the
  280. # newline, so push genuinely rejects and truncates).
  281. overhead = 3 if not self._logs._open_started else 0
  282. if self._pending_chars + newline + overhead > self._logs.remaining:
  283. # The reconstructed first line cannot fit the ledger, so
  284. # LogBuffer would reject it whole: copy only the prefix that
  285. # fails its cheap bound and drop the chunks. The slice is
  286. # bounded HERE, not inside the helper: `text[:newline]` on a
  287. # 340 MiB newline-terminated write is the same full copy the
  288. # join was (measured: MemoryError inside `sys.stdout.write`
  289. # under a 400 MiB addressSpaceMb), and `remaining + 4`
  290. # characters are all the helper can use.
  291. self._push_bounded_prefix(text[: min(newline, self._logs.remaining + 4)])
  292. else:
  293. self._pending.append(text[:newline])
  294. line = "".join(self._pending_parts())
  295. self._pending = []
  296. self._pending_blocks = []
  297. self._pending_chars = 0
  298. self._logs.push(line)
  299. pos = newline + 1
  300. # Scan by offset and STOP once the ledger is exhausted: a single
  301. # write of many newlines (``print("\n" * 1000000)``) would otherwise
  302. # re-slice the tail once per line and keep pushing long after
  303. # LogBuffer truncated, burning the CPU budget on discarded lines.
  304. # `remaining` reads 0 the instant the buffer truncates, so the loop
  305. # exits immediately; the unscanned tail is simply dropped.
  306. while pos < length and self._logs.remaining > 0:
  307. newline = text.find("\n", pos)
  308. if newline < 0:
  309. break
  310. # Bound the SLICE the same way LogBuffer bounds the encode: a
  311. # first line far above the ledger would be copied whole before
  312. # push could reject it, and that copy is the allocation an
  313. # over-budget write cannot afford. Copy only a budget-sized
  314. # prefix, which push still rejects on its own cheap bound (the
  315. # prefix is longer than `remaining`), so the marker is emitted
  316. # and the oversized line is never materialized.
  317. overhead = 3 if not self._logs._open_started else 0
  318. if newline - pos + overhead > self._logs.remaining:
  319. self._logs.push(text[pos:pos + self._logs.remaining + 4])
  320. break
  321. self._logs.push(text[pos:newline])
  322. pos = newline + 1
  323. if pos < length:
  324. if self._logs.remaining > 0:
  325. # Buffer only a budget-sized PREFIX of the tail, not the whole
  326. # `text[pos:]`: an early newline followed by a huge unterminated
  327. # tail (`"\n" + "A" * 30 MiB`) would otherwise copy the entire
  328. # tail into `_pending` here — a second full copy of the model's
  329. # own string, the RLIMIT_AS death this path exists to avoid —
  330. # before the newline-free trigger below could bound it. Anything
  331. # past `remaining` characters cannot be admitted (the char count
  332. # is a lower bound on the serialized cost), so a
  333. # `remaining + 4`-character prefix is all that can ever survive;
  334. # the flush trigger below rejects it and emits the marker.
  335. tail = text[pos:pos + self._logs.remaining + 4]
  336. self._pending.append(tail)
  337. self._pending_chars = len(tail)
  338. else:
  339. # The ledger ran out with text still unscanned, so that text
  340. # IS being dropped and the run must say so. One push is
  341. # enough and is bounded: `remaining` is 0, so LogBuffer's
  342. # cheap length lower bound rejects immediately, emits the
  343. # marker, and never encodes the tail — and a push after the
  344. # marker is already out returns without emitting a second.
  345. # Reaching 0 EXACTLY (65 one-character lines against the
  346. # default 3-byte-per-entry serialized charge) leaves
  347. # `_truncated` unset, so without this the tail vanished with
  348. # no marker at all. Sliced to a budget-sized prefix, not the
  349. # whole tail: the tail can be hundreds of megabytes and the
  350. # copy would be the RLIMIT_AS death this bound exists to
  351. # avoid, while push only needs enough characters to fail its
  352. # own cheap length check.
  353. self._logs.push(text[pos:pos + self._logs.remaining + 4])
  354. else:
  355. self._pending.append(text)
  356. self._pending_chars += len(text)
  357. # Seal the fragment list past the chunk cap: a newline-free drip
  358. # appends one fragment per write, so a 25 M single-character flood
  359. # would accumulate that many list slots (and str objects) long before
  360. # the byte budget is met — the pointer array alone being ~25 M slots.
  361. # Past the cap the current fragments are joined into ONE block and
  362. # moved to `_pending_blocks` (character count unchanged), bounding the
  363. # live fragment count exactly as the host-side `captureStray` seal
  364. # does. The join is only the ≤cap current fragments, never the whole
  365. # accumulated buffer, so a large drip stays O(B) rather than
  366. # re-copying the growing block O(B²/cap) times; a newline never starts
  367. # a multi-byte sequence, so the fragments are un-sealable mid-line.
  368. if len(self._pending) >= self._PENDING_MAX_CHUNKS:
  369. self._pending_blocks.append("".join(self._pending))
  370. self._pending = []
  371. # A newline-free flood must hit the budget while running, not at
  372. # settlement: once the buffered tail alone can no longer fit the
  373. # ledger (chars lower-bound the serialized cost), push it through — LogBuffer
  374. # truncates, emits the marker once, and swallows everything after.
  375. if self._pending_chars > self._logs.remaining:
  376. self._push_bounded_prefix()
  377. return len(text)
  378. def _push_bounded_prefix(self, extra: str = "") -> None:
  379. # Reached only when the buffered characters already exceed what the
  380. # ledger admits, so LogBuffer is certain to reject on its cheap length
  381. # bound and emit the marker. Copy a budget-sized PREFIX rather than the
  382. # joined whole: ``sys.stdout.write("x")`` followed by one newline-free
  383. # 340 MiB write leaves two chunks whose join is a second copy of the
  384. # payload, and under a tight addressSpaceMb that join raises MemoryError
  385. # from inside `write` — surfacing as the program's own exception, or,
  386. # while the oversized chunks stayed retained, again from `flush_line`
  387. # after the program settled, which cost the `done` frame and turned the
  388. # run into a wall-clock timeout instead of the promised truncation
  389. # marker.
  390. #
  391. # The chunks are dropped BEFORE the push so neither this call nor the
  392. # settlement flush can repeat the allocation, and dropping the text is
  393. # exactly what the marker reports. `remaining + 4` is the shortest
  394. # prefix that still fails LogBuffer's ``len(text) + 3 > remaining``
  395. # check; the accumulation stops there, so the copy is bounded by the log
  396. # budget however large the pending chunks are.
  397. #
  398. # `self._pending` is iterated IN PLACE and `extra` handled after it:
  399. # `(*self._pending, extra)` would first copy every pending reference into
  400. # a same-size tuple, which for a single-character drip (millions of tiny
  401. # chunks) is a second pointer array as large as the list itself --
  402. # measured at +80 MiB of tuple on top of a 40 MiB list for 5.2M chunks,
  403. # the allocation this bounded prefix exists to avoid.
  404. limit = self._logs.remaining + 4
  405. parts: list[str] = []
  406. total = 0
  407. for chunk in self._pending_parts():
  408. parts.append(chunk[: limit - total])
  409. total += len(parts[-1])
  410. if total >= limit:
  411. break
  412. else:
  413. # Only reached when the pending chunks did not fill the prefix, so
  414. # `extra` is the one remaining source of text.
  415. parts.append(extra[: limit - total])
  416. self._pending = []
  417. self._pending_blocks = []
  418. self._pending_chars = 0
  419. self._logs.push("".join(parts))
  420. def flush(self) -> None: # noqa: D401 -- inherited contract
  421. # ``TextIOBase.flush`` is a no-op, so without this override an explicit
  422. # ``print(..., flush=True)`` or ``sys.stdout.flush()`` left the text in
  423. # `_pending` with nothing to drain it except `flush_line` after the
  424. # program settles. A run that then hangs or is killed never reaches that
  425. # call: ``print("before hang", end="", flush=True)`` followed by an
  426. # infinite loop returned `logs: []`, losing the one diagnostic the
  427. # program deliberately committed. Forwarding makes an explicit flush emit
  428. # the pending entry immediately, which is what the caller asked for; a
  429. # newline-terminated write already emitted on its own.
  430. self.flush_line()
  431. def flush_line(self) -> None:
  432. """Push any buffered text not terminated by a newline (also serves explicit flushes)."""
  433. # Same shared, re-entrant lock as ``write``: the settlement flush on the
  434. # main coroutine and a daemon thread's concurrent ``write`` both touch
  435. # ``_pending`` and the ledger, so this read-and-clear must be atomic
  436. # against them.
  437. with self._logs.lock:
  438. if self._pending or self._pending_blocks:
  439. # Join, drop the chunks, THEN push — the same join-clear-push order
  440. # as `_write_locked`'s newline branch. Pushing before the clear would keep the
  441. # pending chunks alive through `_push_locked`'s `text.encode`, so
  442. # the chunks, their join, and the encode copy would all be live at
  443. # once; dropping the chunks first leaves only the join and its
  444. # encode, matching that path's peak.
  445. line = "".join(self._pending_parts())
  446. self._pending = []
  447. self._pending_blocks = []
  448. self._pending_chars = 0
  449. # The line has NO trailing newline: mark the frame `open` so the
  450. # host appends the next log frame to the same entry instead of
  451. # inserting a fake newline between two entries.
  452. self._logs.push(line, open=True)
  453. # ---------------------------------------------------------------------------
  454. # Fd-3 channel — line-framed JSON.
  455. # ---------------------------------------------------------------------------
  456. # Non-string scalars only. The string form is scanned by hand in
  457. # :func:`_decode_json_plain` because a ``(?:[^"\\]|\\.)*`` repetition makes
  458. # CPython's backtracking engine retain per-repetition state proportional to the
  459. # string's WIDTH: measured at ~146 MiB of engine state for a 1 MiB string and
  460. # ~558 MiB for 4 MiB, so a legitimate multi-megabyte binding reply raised
  461. # MemoryError out of ``_pump_replies``, leaving its future unsettled until the
  462. # wall clock reported a timeout.
  463. _SCALAR_RE = re.compile(
  464. r'-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null'
  465. )
  466. # A run of ordinary string body characters. The star applies to a CHARACTER
  467. # CLASS, which the engine matches in one linear pass with no backtracking state,
  468. # so the scanner's cost is the number of escapes, not the string's width.
  469. _STRING_CHUNK_RE = re.compile(r'[^"\\]*')
  470. def _decode_json_plain(
  471. text: str,
  472. # Def-time captures: the decoder runs AFTER the program (which is `__main__`)
  473. # may have rebound `__main__.json`, `__main__._SCALAR_RE`,
  474. # `__main__._STRING_CHUNK_RE`, or `__main__.len`; a call-time lookup would
  475. # let a one-line rebind kill the reply pump (a broken decode strands every
  476. # pending Future to the wall clock). Defaults are evaluated at def time.
  477. _json_loads: Any = json.loads,
  478. _scalar_re: Any = _SCALAR_RE,
  479. _string_chunk_re: Any = _STRING_CHUNK_RE,
  480. _len: Any = len,
  481. _isinstance: Any = isinstance,
  482. _str: Any = str,
  483. _list: Any = list,
  484. ) -> Any:
  485. """Parse one JSON document iteratively (no per-level recursion).
  486. ``json.loads`` recurses per nesting level and raises ``RecursionError``
  487. around ~10k levels, but a binding reply is depth-unbounded by the seam
  488. contract — the host's iterative encoder happily produces documents
  489. ``json.loads`` cannot read back. Scalars (numbers, strings with escapes)
  490. are delegated to ``json.loads`` one token at a time, so their grammar and
  491. semantics stay CPython's own; only the container structure is parsed here
  492. with an explicit stack. Raises ``ValueError`` on malformed input; frames
  493. come from the TRUSTED host, so strictness mirrors ``json.loads`` without
  494. extra hostile-input hardening.
  495. """
  496. length = _len(text)
  497. def skip_ws(i: int) -> int:
  498. while i < length and text[i] in " \t\n\r":
  499. i += 1
  500. return i
  501. def scan_string(i: int) -> int:
  502. # Walk chunk by chunk: each match consumes every character up to the next
  503. # quote or backslash, so an escape costs one extra step and a plain body
  504. # costs one pass. Returns the offset just past the closing quote.
  505. j = i + 1
  506. while True:
  507. j = _string_chunk_re.match(text, j).end()
  508. if j >= length:
  509. raise ValueError(f"unterminated string at offset {i}")
  510. char = text[j]
  511. if char == '"':
  512. return j + 1
  513. # text[j] is a backslash: skip it and the character it escapes. A
  514. # trailing backslash runs j past `length`, caught on the next pass.
  515. j += 2
  516. def scalar(i: int):
  517. if i < length and text[i] == '"':
  518. end = scan_string(i)
  519. return _json_loads(text[i:end]), end
  520. match = _scalar_re.match(text, i)
  521. if match is None:
  522. raise ValueError(f"invalid JSON at offset {i}")
  523. return _json_loads(match.group(0)), match.end()
  524. def string_key(i: int):
  525. key, end = scalar(i)
  526. if not _isinstance(key, _str):
  527. raise ValueError(f"object key must be a string at offset {i}")
  528. end = skip_ws(end)
  529. if end >= length or text[end] != ":":
  530. raise ValueError(f"expected ':' at offset {end}")
  531. return key, skip_ws(end + 1)
  532. # Frames: a list, or (dict, pending key). `value`/`have_value` carry each
  533. # completed value up to its parent frame.
  534. stack: list[Any] = []
  535. value: Any = None
  536. have_value = False
  537. i = skip_ws(0)
  538. while True:
  539. if not have_value:
  540. ch = text[i] if i < length else ""
  541. if ch == "[":
  542. i = skip_ws(i + 1)
  543. if i < length and text[i] == "]":
  544. i += 1
  545. value, have_value = [], True
  546. else:
  547. stack.append([])
  548. continue
  549. elif ch == "{":
  550. i = skip_ws(i + 1)
  551. if i < length and text[i] == "}":
  552. i += 1
  553. value, have_value = {}, True
  554. else:
  555. key, i = string_key(i)
  556. stack.append(({}, key))
  557. continue
  558. else:
  559. value, i = scalar(i)
  560. have_value = True
  561. if not stack:
  562. i = skip_ws(i)
  563. if i != length:
  564. raise ValueError(f"trailing data at offset {i}")
  565. return value
  566. top = stack[-1]
  567. i = skip_ws(i)
  568. ch = text[i] if i < length else ""
  569. if _isinstance(top, _list):
  570. top.append(value)
  571. if ch == ",":
  572. i = skip_ws(i + 1)
  573. have_value = False
  574. elif ch == "]":
  575. i += 1
  576. stack.pop()
  577. value = top
  578. else:
  579. raise ValueError(f"expected ',' or ']' at offset {i}")
  580. else:
  581. container, key = top
  582. container[key] = value
  583. if ch == ",":
  584. key, i = string_key(skip_ws(i + 1))
  585. stack[-1] = (container, key)
  586. have_value = False
  587. elif ch == "}":
  588. i += 1
  589. stack.pop()
  590. value = container
  591. else:
  592. raise ValueError(f"expected ',' or '}}' at offset {i}")
  593. class ProtocolChannel:
  594. """Blocking readers and synchronous writers over the fd-3 protocol pipe.
  595. Writes are unbuffered and go straight to the fd, so ``send_sync`` is safe
  596. from inside model code (which may run outside an asyncio task) and from
  597. background tasks alike. Concurrent writers are serialized by ``_write_lock``
  598. around a full-write loop (see ``send_sync``): ``os.write`` releases the GIL,
  599. a frame may exceed ``PIPE_BUF`` (logs up to ``maxLogBytes``, completions up
  600. to ``maxValueBytes``, uncapped ``call`` args), and one ``os.write`` may
  601. consume only part of a frame — so neither the GIL nor per-frame atomicity is
  602. relied on for framing.
  603. """
  604. def __init__(self, fd: int) -> None:
  605. self._fd = fd
  606. # Residual bytes read past a frame's newline, shared by the blocking and
  607. # async readers. Held here, not in the reading coroutine: the reply pump
  608. # is cancelled once `done` is posted, and read-ahead sitting in a local
  609. # would be lost with it. Both readers use `os.read(self._fd, ...)`
  610. # directly, so no buffered file object wraps the fd.
  611. self._pending = bytearray()
  612. # Serializes writers: os.write releases the GIL, and a frame larger
  613. # than PIPE_BUF is neither atomic nor guaranteed fully consumed by one
  614. # call — without the lock, model-created threads printing while a big
  615. # completion frame drains could interleave bytes mid-frame.
  616. self._write_lock = threading.Lock()
  617. def read_frame(
  618. self,
  619. # Def-time captures for the decode primitives (see _decode_json_plain):
  620. # this runs before the program, but the reply path does not — a rebind
  621. # of `__main__._decode_json_plain`/`__main__.os`/`__main__._READ_CHUNK_BYTES`
  622. # must not break the pump.
  623. _decode: Any = _decode_json_plain,
  624. _os_read: Any = os.read,
  625. _read_chunk: int = _READ_CHUNK_BYTES,
  626. _bytes: Any = bytes,
  627. _len: Any = len,
  628. ) -> dict[str, Any] | None:
  629. """Read one JSON-line frame (iteratively decoded). ``None`` on EOF.
  630. Blocking. Used for the two frames read BEFORE the model program starts
  631. (``boot`` and ``run``), where blocking is what the handshake wants. Reply
  632. frames arriving during the program go through :meth:`read_frame_async`,
  633. which must not occupy a thread.
  634. Reads in CHUNKS into the shared ``_pending`` buffer rather than through
  635. ``FileIO.readline()``: the fd is unbuffered (``buffering=0``), so
  636. ``readline`` issues one ``os.read(1)`` per byte, and a multi-megabyte
  637. ``run`` frame — RLIMIT_CPU already in force by then — would burn the
  638. budget in millions of syscalls before ``ast.parse`` even runs. The chunk
  639. reads and the same residual buffer the async path uses keep read-ahead
  640. past a newline for the next frame.
  641. """
  642. # Scan only the bytes not yet examined: `find` from a running offset so a
  643. # frame arriving in N chunks costs one linear pass total, not one rescan
  644. # of the whole buffer per chunk (which is quadratic in the frame size).
  645. scanned = 0
  646. while True:
  647. newline = self._pending.find(b"\n", scanned)
  648. if newline >= 0:
  649. line = _bytes(self._pending[:newline])
  650. del self._pending[: newline + 1]
  651. return _decode(line.decode("utf-8"))
  652. scanned = _len(self._pending)
  653. chunk = _os_read(self._fd, _read_chunk)
  654. if not chunk:
  655. # EOF before a newline: drop the partial line, as the host drops
  656. # a frame that never completed.
  657. return None
  658. self._pending.extend(chunk)
  659. async def read_frame_async(
  660. self,
  661. # Def-time captures, same rationale as read_frame.
  662. _decode: Any = _decode_json_plain,
  663. _os_read: Any = os.read,
  664. _read_chunk: int = _READ_CHUNK_BYTES,
  665. _bytes: Any = bytes,
  666. _get_event_loop: Any = asyncio.get_event_loop,
  667. _len: Any = len,
  668. ) -> dict[str, Any] | None:
  669. """Await one JSON-line frame without occupying a thread. ``None`` on EOF.
  670. ``loop.run_in_executor(None, read_frame)`` was the obvious spelling and
  671. the wrong one: the default executor spins up its first thread the moment
  672. the program awaits a binding, and on Linux/glibc that thread's 8 MiB
  673. stack plus a 64 MiB per-thread malloc arena reservation are charged to
  674. ``RLIMIT_AS`` — measured, the child's mappings went from 30.34 MiB to
  675. 102.39 MiB across one ``await tools.*``. Since the limit is already in
  676. force, that ~72 MiB comes straight out of the run's ``addressSpaceMb``:
  677. under a small limit the thread cannot start at all and a legitimate
  678. binding call hangs to ``maxWallMs``, and under a larger one an allocation
  679. that should have fit dies as ``MemoryError``. This is the same accounting
  680. the settlement-time CPU recheck was designed around, where a sampling
  681. thread cost the same 72 MiB.
  682. `loop.add_reader` watches the fd instead, so no thread exists.
  683. Bytes past a frame's newline belong to the next frame, so the residual
  684. lives on the CHANNEL rather than in this coroutine: the pump is cancelled
  685. once ``done`` is posted, and a local buffer would discard whatever it had
  686. read ahead.
  687. """
  688. loop = _get_event_loop()
  689. # Scan only the not-yet-examined bytes (running offset), so a frame
  690. # arriving across many reads costs one linear pass, not a quadratic
  691. # rescan of the whole buffer per read.
  692. scanned = 0
  693. while True:
  694. newline = self._pending.find(b"\n", scanned)
  695. if newline >= 0:
  696. line = _bytes(self._pending[:newline])
  697. del self._pending[: newline + 1]
  698. return _decode(line.decode("utf-8"))
  699. scanned = _len(self._pending)
  700. ready = loop.create_future()
  701. # `add_reader` only reports readability; the read itself happens here,
  702. # and `os.read` returns whatever is buffered without waiting for more.
  703. loop.add_reader(self._fd, lambda: ready.done() or ready.set_result(None))
  704. try:
  705. await ready
  706. finally:
  707. loop.remove_reader(self._fd)
  708. chunk = _os_read(self._fd, _read_chunk)
  709. if not chunk:
  710. # EOF. Any partial line is dropped, matching how the host drops a
  711. # frame that never completed.
  712. return None
  713. self._pending.extend(chunk)
  714. def send_sync(self, message: dict[str, Any]) -> None:
  715. """Post one frame synchronously.
  716. Encoded with the iterative :func:`_encode_json_plain` (not
  717. ``json.dumps``, whose per-level recursion would raise
  718. ``RecursionError`` on a deeply nested completion or call argument the
  719. depth-unbounded ``CodeJsonValue`` contract admits). NaN/Infinity still
  720. raise ``ValueError`` — they would serialize as non-standard tokens
  721. that Node's ``JSON.parse`` rejects, silently dropping the frame, and a
  722. call would then hang until the wall clock instead of failing fast.
  723. Callers turn the ``ValueError`` into their own contract error
  724. (dispatch raises the lossless-JSON message).
  725. """
  726. self.write_encoded(_encode_json_plain(message))
  727. def write_encoded(self, frame: str) -> None:
  728. """Write a frame that is ALREADY encoded to its JSON string form.
  729. Appends the frame's trailing newline and full-write-loops the bytes
  730. under the writer lock, identically to :meth:`send_sync`. The consumer
  731. supplies the encoded JSON (a ``"done"`` frame carrying a completion
  732. value that was serialized at its validation point — see
  733. :func:`_done_with_value`); the channel does not re-encode it, so the
  734. bytes written are exactly what was validated with no second traversal
  735. of a live object.
  736. """
  737. payload = (frame + "\n").encode("utf-8")
  738. # Full-write loop under the writer lock (same rationale as send_sync):
  739. # one os.write may consume only part of a frame beyond PIPE_BUF, and a
  740. # partial or interleaved frame is dropped host-side as malformed JSON.
  741. with self._write_lock:
  742. view = memoryview(payload)
  743. while view:
  744. view = view[os.write(self._fd, view):]
  745. # ---------------------------------------------------------------------------
  746. # Tools proxy — turns ``await tools.name(args)`` into a fd-3 call frame.
  747. # ---------------------------------------------------------------------------
  748. class _Namespace:
  749. """A proxy for one binding namespace: every declared name routes to the bridge.
  750. Names arrive from :class:`BootMessage.namespaces`. Both attribute access
  751. (``tools.name``) and subscript access (``tools["my-tool"]`` — the SDK's
  752. escape hatch for exotic or reserved names, which are legal function names
  753. on the wire) return a coroutine factory that posts a ``call`` frame and
  754. awaits the matching ``reply``. An undeclared name raises ``AttributeError``
  755. (attribute) or ``KeyError`` (subscript), matching the worker backend's
  756. own-property discipline.
  757. ``__getattribute__`` (not ``__getattr__``) intercepts attribute access so a
  758. declared name ALWAYS reaches the bridge — even one that collides with an
  759. inherited attribute like ``__class__``, which ordinary lookup would resolve
  760. on ``object`` before ``__getattr__`` ever ran. Internal state lives under
  761. name-mangled ``_Namespace__*`` attributes; a declared binding with such a
  762. name still wins (declared-names check runs first).
  763. """
  764. def __init__(self, global_name: str, names: list[str], dispatch) -> None:
  765. self.__global = global_name
  766. self.__names = set(names)
  767. self.__dispatch = dispatch
  768. def __call_for(self, name: str):
  769. dispatch = object.__getattribute__(self, "_Namespace__dispatch")
  770. global_name = object.__getattribute__(self, "_Namespace__global")
  771. async def call(args: Any) -> Any:
  772. return await dispatch(global_name, name, args)
  773. return call
  774. def __getattribute__(self, name: str):
  775. # Declared names route to the bridge unconditionally — before Python
  776. # can resolve an inherited attribute (``__class__``) or our own
  777. # internals. Everything else falls through to normal lookup so the
  778. # proxy machinery itself keeps working.
  779. names = object.__getattribute__(self, "_Namespace__names")
  780. if name in names:
  781. return object.__getattribute__(self, "_Namespace__call_for")(name)
  782. return object.__getattribute__(self, name)
  783. def __getattr__(self, name: str):
  784. # Reached only when normal lookup found nothing (declared names were
  785. # already intercepted above), so this is always an undeclared tool.
  786. raise AttributeError(
  787. f"tool {name!r} is not declared in namespace "
  788. f"{object.__getattribute__(self, '_Namespace__global')!r}"
  789. )
  790. def __getitem__(self, name: str):
  791. names = object.__getattribute__(self, "_Namespace__names")
  792. if name not in names:
  793. raise KeyError(
  794. f"tool {name!r} is not declared in namespace "
  795. f"{object.__getattribute__(self, '_Namespace__global')!r}"
  796. )
  797. return object.__getattribute__(self, "_Namespace__call_for")(name)
  798. class _BindingRejection(Exception):
  799. """Internal reply-pump rejection, converted by ``dispatch`` into the
  800. namespace's declared error class (or ``RuntimeError``) so the marker type
  801. itself never reaches model code."""
  802. def _make_error_class(
  803. name: str,
  804. member_name_property: str,
  805. # Def-time captures (see the __init__ body): the minted class runs AFTER
  806. # the program, so `__main__.Exception`/`__main__.setattr` rebinds must not
  807. # break the rejection constructor.
  808. _Exception_init: Any = Exception,
  809. _setattr: Any = setattr,
  810. ) -> type:
  811. """Mint one program-visible rejection class per the seam's
  812. ``CodeBindingErrorClass`` contract: instances carry the failed member name
  813. under ``member_name_property`` and render as their message."""
  814. def __init__(self, member_name: str, message: str) -> None: # noqa: N807
  815. # Exception.__init__ and setattr are captured as defaults at def time:
  816. # the class constructor runs model-visible code paths, and a rebind of
  817. # `__main__.Exception`/`__main__.setattr` must not break a rejection.
  818. _Exception_init.__init__(self, message)
  819. _setattr(self, member_name_property, member_name)
  820. return type(name, (Exception,), {"__init__": __init__})
  821. def _clamped(which: int, soft: int, hard: int) -> tuple[int, int]:
  822. """Bound a requested (soft, hard) rlimit pair by BOTH inherited limits.
  823. An unprivileged process may lower a hard limit but never raise it, so a
  824. harness already started under a tighter ceiling (``ulimit -v`` below
  825. ``addressSpaceBytes``, or a CPU cap below ``cpuSeconds`` + 1) would make
  826. ``setrlimit`` raise ``ValueError`` and fail every run — despite the
  827. inherited limit being STRONGER than the one requested. Clamping keeps the
  828. stricter of the two, which still satisfies the containment contract.
  829. Both inherited bounds matter, not just the hard one. A deployment that
  830. inherited a soft limit BELOW what is requested (e.g. inherited ``(100, 200)``,
  831. requested ``(150, 160)``) must keep the stricter soft — returning the
  832. requested ``150`` would RAISE the effective soft limit, loosening RLIMIT_AS
  833. memory or deferring the RLIMIT_CPU SIGXCPU, the opposite of "strictest of
  834. configured and inherited". So each side is clamped against its inherited
  835. counterpart. ``RLIM_INFINITY`` compares as -1, so an infinite inherited bound
  836. imposes no ceiling and the requested value stands.
  837. """
  838. inherited_soft, inherited_hard = resource.getrlimit(which)
  839. clamped_soft = soft if inherited_soft == resource.RLIM_INFINITY else min(soft, inherited_soft)
  840. clamped_hard = hard if inherited_hard == resource.RLIM_INFINITY else min(hard, inherited_hard)
  841. # setrlimit requires soft <= hard. Clamping the two sides independently can
  842. # invert them (a finite inherited soft below the clamped hard is fine, but a
  843. # requested hard below the inherited soft would leave soft > hard), so pin
  844. # soft under hard as the final step; the stricter hard ceiling wins.
  845. result_soft = min(clamped_soft, clamped_hard)
  846. result_hard = clamped_hard
  847. # For RLIMIT_CPU, a soft limit EQUAL to the hard limit leaves the kernel no
  848. # window to send SIGXCPU: it checks the hard limit in the same tick and
  849. # SIGKILLs directly (a `ulimit -t N` sets both, and a busy loop then dies by
  850. # SIGKILL, not SIGXCPU). The host classifies a CPU overrun ONLY on ``signal
  851. # === 'SIGXCPU'``, so a definite budget exhaustion would be misreported as a
  852. # `worker-exit`. Lowering the soft limit one unit below the hard (when the
  853. # hard is at least 2, so soft stays positive) keeps the stricter-of-the-two
  854. # containment semantics while giving SIGXCPU a window to fire — the CPU
  855. # overrun is then reported as a timeout, not a worker-exit. This is scoped to
  856. # RLIMIT_CPU: for RLIMIT_AS a one-byte soft differential would only misalign
  857. # the child's applied limit with the host-side budget gate, with no signal to
  858. # preserve. The ``hard >= 2`` guard leaves the ``hard == 1`` blind spot
  859. # (a 1-second dual limit cannot lower soft to 0) — a definite CPU overrun
  860. # there is still reported as `worker-exit`; see the settlement note.
  861. if (
  862. which == resource.RLIMIT_CPU
  863. and result_soft == result_hard
  864. and result_hard >= 2
  865. ):
  866. result_soft = result_hard - 1
  867. return (result_soft, result_hard)
  868. # ---------------------------------------------------------------------------
  869. # Main.
  870. # ---------------------------------------------------------------------------
  871. async def _run(channel: ProtocolChannel) -> None:
  872. # The exception class every `except` clause in this function catches is bound
  873. # into a LOCAL at the very top, before any model code runs. This bootstrap IS
  874. # `__main__`, so `__main__.BaseException = RuntimeError` would otherwise
  875. # rebind the module global the `except BaseException` clauses resolve at
  876. # runtime — and a program exception that no longer matches would escape `_run`
  877. # with no done frame, misreporting the run as a `worker-exit`. A frame local
  878. # is not reachable by `__main__._X = ...`, so the catch is immune.
  879. _BaseException = BaseException
  880. # The child inherits the host's SIGXCPU disposition and signal mask. If
  881. # the host ignores or blocks SIGXCPU, the soft RLIMIT_CPU fires but cannot
  882. # stop the child — the hard limit's SIGKILL then classifies a definite CPU
  883. # overrun as substrate death (worker-exit) instead of a timeout. Reset to
  884. # the default disposition and unblock HERE, before the resource-limit setup
  885. # and the boot-namespace construction (which can burn CPU): a huge
  886. # namespace under an inherited ignore would otherwise reach the hard limit
  887. # inside that window. The settle-time enforcer already restores SIG_DFL for
  888. # a program that traps or masks the signal mid-run; this closes the
  889. # inherited-state gap.
  890. signal.signal(signal.SIGXCPU, signal.SIG_DFL)
  891. if getattr(signal, "pthread_sigmask", None) is not None:
  892. signal.pthread_sigmask(signal.SIG_UNBLOCK, (signal.SIGXCPU,))
  893. # `RuntimeError` and the `_BindingRejection` marker class are likewise bound
  894. # into locals: `dispatch`'s `call_failure` and its `except` clause resolve
  895. # them at call time, and the program (running as `__main__`) can rebind the
  896. # module globals — `__main__._BindingRejection = ValueError` would leak the
  897. # marker type into model code, violating the class's conversion contract.
  898. # The names differ from the module globals (`_RuntimeError_cls`) so the
  899. # assignment RHS resolves the module global, not an unbound local.
  900. _RuntimeError_cls = RuntimeError
  901. _BindingRejection_cls = _BindingRejection
  902. # `str` for dispatch's rejection conversion is likewise bound: a program
  903. # rebinding `__main__.str` would otherwise run a hostile callable when the
  904. # binding-rejection message is formatted. `isinstance` for `send_done`'s
  905. # frame-shape check is bound the same way.
  906. _str = str
  907. _isinstance = isinstance
  908. # dispatch's argument-validation, event-loop, and frame-send primitives are
  909. # bound here, before the program runs: a rebind of `__main__._lossless_json_violation`,
  910. # `__main__.asyncio`, or the channel's send method must not turn a legitimate
  911. # binding call into an exception or a wall-clock timeout.
  912. _lossless_json_violation_cls = _lossless_json_violation
  913. _get_event_loop_cls = asyncio.get_event_loop
  914. _send_sync_cls = channel.send_sync
  915. # The frame WRITE primitives are bound too: send_sync's body resolves
  916. # `_encode_json_plain` (module global) and `self.write_encoded` (class
  917. # attribute) at call time, so a program rebinding either before the first
  918. # binding call could turn a legitimate call into an exception. dispatch
  919. # and the log sink both write through these directly (the sink does NOT go
  920. # through send_sync, whose body resolves the same names at call time).
  921. _write_encoded_cls = channel.write_encoded
  922. _encode_plain_cls = _encode_json_plain
  923. # 1. Boot handshake.
  924. boot = channel.read_frame()
  925. if boot is None or boot.get("type") != "boot":
  926. raise RuntimeError("bootstrap: expected boot frame on fd 3")
  927. # A limit that cannot be applied must fail the run as a diagnosable done
  928. # frame, not a bare traceback + exit(1): running the program UNCAPPED would
  929. # silently void the containment contract, and the host can only relay what
  930. # rides the protocol.
  931. try:
  932. # SIGXCPU's default disposition (how the soft CPU limit stops the child)
  933. # dumps core, and the child inherits the host's RLIMIT_CORE — a CPU
  934. # timeout would otherwise write a large memory-bearing core file into
  935. # the workspace. Forbid core dumps first so the timeout path leaves none.
  936. resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
  937. # Soft limit at cpuSeconds fires SIGXCPU (its default disposition
  938. # terminates the child; the host classifies that close as a timeout).
  939. # Hard limit at +1s is a SIGKILL backstop for a program that traps
  940. # SIGXCPU and keeps burning CPU.
  941. cpu_soft, cpu_hard = _clamped(
  942. resource.RLIMIT_CPU, boot["cpuSeconds"], boot["cpuSeconds"] + 1
  943. )
  944. resource.setrlimit(resource.RLIMIT_CPU, (cpu_soft, cpu_hard))
  945. # Darwin maps the multi-GB dyld shared cache into every process at
  946. # exec, so any practical RLIMIT_AS cap sits below current usage and
  947. # the kernel rejects it — the child would die here on every run. Skip
  948. # the address-space cap there; RLIMIT_CPU and the host's wall-clock
  949. # ceiling still bound the run.
  950. if sys.platform != "darwin":
  951. addr_bytes = int(boot["addressSpaceBytes"])
  952. effective_as = _clamped(resource.RLIMIT_AS, addr_bytes, addr_bytes)
  953. resource.setrlimit(resource.RLIMIT_AS, effective_as)
  954. # The host rejected an output budget too large for the CONFIGURED
  955. # addressSpaceMb, but a launch environment can inherit a STRICTER
  956. # RLIMIT_AS (e.g. a `ulimit -v` wrapper below addressSpaceMb), which
  957. # `_clamped` correctly lowers the effective limit to — leaving the
  958. # budgets validated against a ceiling the child never gets. Re-check
  959. # both budgets against the EFFECTIVE soft limit here, mirroring the
  960. # host gate (each budget times the worst-case Unicode multiple must
  961. # fit the room left after the interpreter baseline), and fail loud at
  962. # boot rather than letting a near-budget output OOM mid-run. The
  963. # constants match src/index.ts's OUTPUT_BUDGET_WORST_CASE_ADDRESS_
  964. # SPACE_MULTIPLE and INTERPRETER_BASELINE_BYTES.
  965. # `effective_soft` is always finite, so the re-check is
  966. # unconditional: `_clamped` was asked for the finite `addr_bytes` on
  967. # both sides, and each of its branches returns either that value or a
  968. # `min` with an inherited bound -- RLIM_INFINITY is not reachable. A
  969. # guard here would have silently skipped the whole re-check on the
  970. # branch it claimed to protect.
  971. effective_soft = effective_as[0]
  972. budgetable = effective_soft - _INTERPRETER_BASELINE_BYTES
  973. for _budget_key in ("maxLogBytes", "maxValueBytes"):
  974. if int(boot[_budget_key]) * _OUTPUT_BUDGET_WORST_CASE_MULTIPLE >= budgetable:
  975. raise ValueError(
  976. "config.%s is too large for the inherited RLIMIT_AS of %d bytes "
  977. "(a near-budget output would breach it during encode); "
  978. "lower the budget or raise the inherited address-space limit"
  979. % (_budget_key, effective_soft)
  980. )
  981. except _BaseException as exc: # noqa: BLE001 -- report every failure to host; `_BaseException` is a pre-program local
  982. channel.send_sync(
  983. {
  984. "type": "done",
  985. "error": {
  986. "kind": "exception",
  987. # Exception-only rendering: format_exc() would embed the
  988. # absolute installed bootstrap.py path in model-visible
  989. # durable output, leaking host paths into transcripts.
  990. "message": "bootstrap: applying resource limits failed\n"
  991. + "".join(
  992. traceback.format_exception_only(type(exc), exc)
  993. ),
  994. },
  995. }
  996. )
  997. return
  998. logs = LogBuffer(
  999. int(boot["maxLogBytes"]),
  1000. # The sink writes through the def-time bound encode+write primitives
  1001. # (not _send_sync_cls, whose body still resolves _encode_json_plain and
  1002. # self.write_encoded at call time) so a rebind cannot break a log frame.
  1003. sink=lambda text, truncated=False, open=False: _write_encoded_cls(
  1004. _encode_plain_cls(
  1005. {
  1006. "type": "log",
  1007. "text": text,
  1008. **({"truncated": True} if truncated else {}),
  1009. **({"open": True} if open else {}),
  1010. }
  1011. )
  1012. ),
  1013. )
  1014. # 2. Wire the tools proxies and the ack.
  1015. #
  1016. # Each entry records the reply Future AND the loop it was created on. Model
  1017. # code may call a binding from a THREAD it started, spelled
  1018. # ``asyncio.run(tools.x(...))`` or its own new loop in that thread, so a
  1019. # Future here can belong to a loop other than the one ``_pump_replies`` runs
  1020. # on. ``asyncio.Future`` is not thread-safe: completing it from another
  1021. # thread does not wake its own loop, so the pump schedules the completion on
  1022. # the owning loop via ``call_soon_threadsafe`` (see ``_pump_replies``) rather
  1023. # than calling ``set_result`` directly.
  1024. pending: dict[int, tuple[asyncio.AbstractEventLoop, asyncio.Future[Any]]] = {}
  1025. next_id = 0
  1026. # Serializes the id claim + write + counter advance in ``dispatch`` against
  1027. # both other binding-calling threads and the pump's ``pop``. ``dispatch`` may
  1028. # run concurrently on several loops/threads, and the host answers a ``call``
  1029. # only when its id is the exact successor of the last one — so ids must reach
  1030. # the wire in the order they are claimed. Holding this lock across the write
  1031. # (not just the counter arithmetic) is what keeps two threads' frames from
  1032. # interleaving on fd 3 out of id order, which the host would reject.
  1033. pending_lock = threading.Lock()
  1034. error_classes: dict[str, type] = {}
  1035. async def dispatch(global_name: str, name: str, args: Any) -> Any:
  1036. nonlocal next_id
  1037. error_class = error_classes.get(global_name)
  1038. def call_failure(message: str) -> BaseException:
  1039. # The namespace's declared rejection contract (e.g. PTC mode's
  1040. # ToolCallError with .toolName) when present; RuntimeError keeps
  1041. # the pre-errorClass behavior for namespaces that declared none.
  1042. if error_class is not None:
  1043. return error_class(name, message)
  1044. return _RuntimeError_cls(message)
  1045. # Validate the argument shape before claiming an id, so a rejected call
  1046. # leaves no gap in the sequence the host checks. json.dumps would coerce
  1047. # a non-string dict key or non-finite float rather than raise (allow_nan
  1048. # is off, but key coercion still slips through), silently corrupting what
  1049. # the tool receives. Reject up front through the call's error contract.
  1050. violation = _lossless_json_violation_cls(args)
  1051. if violation is not None:
  1052. raise call_failure(f"binding arguments must be lossless JSON ({violation})")
  1053. # Ids are consecutive from 0 with NO gaps: the host answers a `call` only
  1054. # when its id is the exact successor of the last one, which bounds the
  1055. # state it retains to a single number. A frame that never reaches the
  1056. # host must therefore not consume an id, so the counter advances only
  1057. # once the write has succeeded.
  1058. #
  1059. # The whole claim-write-advance runs under ``pending_lock`` because a
  1060. # binding may be called from more than one thread/loop at once (the model
  1061. # can start a thread that runs ``asyncio.run(tools.x(...))``). Without
  1062. # the lock two callers could claim the same id, or write their frames to
  1063. # fd 3 in an order that does not match their ids — either of which the
  1064. # host rejects as an out-of-sequence call. The Future's own loop is
  1065. # captured here so ``_pump_replies`` can complete it thread-safely.
  1066. loop = _get_event_loop_cls()
  1067. with pending_lock:
  1068. call_id = next_id
  1069. fut: asyncio.Future[Any] = loop.create_future()
  1070. pending[call_id] = (loop, fut)
  1071. try:
  1072. _write_encoded_cls(
  1073. _encode_plain_cls(
  1074. {
  1075. "type": "call",
  1076. "id": call_id,
  1077. "global": global_name,
  1078. "name": name,
  1079. "args": args,
  1080. }
  1081. )
  1082. )
  1083. except (TypeError, ValueError) as exc:
  1084. pending.pop(call_id, None)
  1085. raise call_failure(
  1086. f"binding arguments must be lossless JSON: {exc}"
  1087. ) from exc
  1088. next_id += 1
  1089. try:
  1090. return await fut
  1091. except _BindingRejection_cls as exc:
  1092. raise call_failure(_str(exc)) from None
  1093. namespaces: dict[str, Any] = {}
  1094. for entry in boot["namespaces"]:
  1095. namespaces[entry["global"]] = _Namespace(
  1096. entry["global"], entry["names"], dispatch
  1097. )
  1098. declared = entry.get("errorClass")
  1099. if declared:
  1100. error_class = _make_error_class(
  1101. declared["name"], declared["memberNameProperty"]
  1102. )
  1103. error_classes[entry["global"]] = error_class
  1104. # The class is program-visible under its own name so model code
  1105. # can `except ToolCallError as e:` and read the member property.
  1106. namespaces[declared["name"]] = error_class
  1107. channel.send_sync({"type": "boot-ack"})
  1108. # 3. Start a reply-pump task before the run message: replies can arrive
  1109. # interleaved with the run's own binding traffic.
  1110. # The pump's frame reader is bound here, before the program runs: the
  1111. # pump itself starts AFTER the program's top-level statements (no suspension
  1112. # point between create_task and `await __dsh_main__`), so a body-local
  1113. # `channel.read_frame_async` lookup would resolve a rebound class method.
  1114. pump_read = channel.read_frame_async
  1115. reply_task = asyncio.get_event_loop().create_task(
  1116. _pump_replies(channel, pending, pending_lock, pump_read)
  1117. )
  1118. # 4. Read the run message.
  1119. run = channel.read_frame()
  1120. if run is None or run.get("type") != "run":
  1121. reply_task.cancel()
  1122. raise RuntimeError("bootstrap: expected run frame on fd 3")
  1123. program: str = run["program"]
  1124. # 5. Install log capture — ``print``, tracebacks, and ordinary ``sys.stdout``
  1125. # writes funnel into the LogBuffer. The real fds stay open (host uses
  1126. # stderr for stray-byte accounting) but the Python-visible streams point
  1127. # at the buffer.
  1128. sys.stdout = _LogStream(logs) # type: ignore[assignment]
  1129. sys.stderr = _LogStream(logs) # type: ignore[assignment]
  1130. out_stream, err_stream = sys.stdout, sys.stderr
  1131. # The ORIGINAL std streams are bound here, before the program runs, so the
  1132. # settlement flush can drain bytes a program wrote through them without an
  1133. # explicit flush. The bootstrap only replaces `sys.stdout`/`sys.stderr` with
  1134. # the `_LogStream`; `sys.__stdout__`/`sys.__stderr__` (and C-ext stdio
  1135. # layered on the same fds) are untouched, and their block-buffered bytes are
  1136. # lost when the host SIGTERMs the child right after the done frame — the
  1137. # default SIGTERM disposition terminates without interpreter finalization.
  1138. # Binding the names here (before the program) makes them immune to a
  1139. # `sys.__stdout__ = boom` rebind in model code; `None` under `-S`-style
  1140. # redirects is guarded at flush time.
  1141. # Bind the FLUSH METHODS, not the stream objects: the settlement flush
  1142. # loop iterates callables, and a bare TextIOWrapper object is not callable —
  1143. # invoking it would raise TypeError and be swallowed by the loop's except,
  1144. # silently disabling the drain. A bound method captures its stream at
  1145. # binding time, so a later `sys.__stdout__ = boom` rebind cannot redirect
  1146. # it; `None` (stream absent) is guarded at flush time.
  1147. _stdout_orig = sys.__stdout__.flush if sys.__stdout__ is not None else None
  1148. _stderr_orig = sys.__stderr__.flush if sys.__stderr__ is not None else None
  1149. # 6. Compile the program as the body of an async function, matching the
  1150. # seam contract (`CodeRunRequest.program` is an async-function body: top-level
  1151. # `await` and `return` both work, and the returned value is the completion).
  1152. # AST-splicing the parsed body into an `async def` keeps every statement's
  1153. # original line number, so a traceback points at the model's own source.
  1154. ns: dict[str, Any] = {
  1155. "__name__": "__main__",
  1156. "__builtins__": __builtins__,
  1157. **namespaces,
  1158. }
  1159. # Read the enforcement callable and its budget into this frame's locals
  1160. # BEFORE the program runs: model code can rebind this module's globals
  1161. # (the bootstrap IS ``__main__``), and a frame local is not a module
  1162. # attribute, so a later ``__main__._DIE_IF_CPU_EXHAUSTED = ...`` cannot
  1163. # change which callable the post-check below invokes. This defeats the
  1164. # one-line rebind, not a determined `sys._getframe` walk; the unforgeable
  1165. # bounds are the RLIMIT_CPU hard limit and the host wall clock
  1166. # (see _make_cpu_enforcer).
  1167. die_if_cpu_exhausted = _DIE_IF_CPU_EXHAUSTED
  1168. # The settlement recheck compares against the EFFECTIVE soft CPU limit
  1169. # (`cpu_soft`, clamped to any stricter inherited limit above), NOT the
  1170. # configured `cpuSeconds`. When the deployment inherited a soft limit below
  1171. # the configured value, a program that traps SIGXCPU, burns past the
  1172. # inherited soft, and returns inside the soft-to-hard gap must be reported as
  1173. # a timeout — checking the configured value would falsely pass it and bypass
  1174. # the inherited limit.
  1175. cpu_seconds = cpu_soft
  1176. # Same capture, same reason, for the failure path and the send that follows
  1177. # it. The reporter was a module-global lookup inside the `except` block, so
  1178. # ``import __main__; __main__._SAFE_MODEL_TRACEBACK = ...`` put model code
  1179. # there with no guard around it; the flush and send were attribute lookups
  1180. # on the stream and channel CLASSES, which ``__main__._LogStream.flush_line
  1181. # = ...`` rebinds just as easily. All four run AFTER the handler, where a
  1182. # throw costs the `done` frame and the host reports a wall-clock timeout
  1183. # instead of the model's exception. Binding the callables now fixes what
  1184. # runs; what they in turn reach is closed over in _make_failure_reporter.
  1185. safe_model_traceback = _SAFE_MODEL_TRACEBACK
  1186. flush_out = out_stream.flush_line
  1187. flush_err = err_stream.flush_line
  1188. # `done` is either a pre-encoded frame STRING (a `_done_with_value` success:
  1189. # the completion value was serialized at its validation point, inside the try,
  1190. # so a later send never re-walks the live value a mutating daemon thread could
  1191. # have changed) or a dict ERROR frame (a rejection or the exception handler,
  1192. # which carry no live model value). `send_done` posts whichever form, going
  1193. # DIRECTLY through a bound `_encode_json_plain` and a bound `write_encoded` —
  1194. # never through `channel.send_sync`, whose body re-resolves `self.write_encoded`
  1195. # and `self`'s module-level `_encode_json_plain` at call time.
  1196. #
  1197. # The program runs as `__main__`, so `import __main__; __main__.ProtocolChannel
  1198. # .send_sync = boom` or `__main__._encode_json_plain = boom` would otherwise
  1199. # re-resolve the send/encode to a rebranded callable at call time and, when
  1200. # that replacement raises, skip the `done` frame and downgrade a settled
  1201. # verdict to a host-side worker-exit (the binding-all-names regression test
  1202. # pins this). Same reason `flush_out`/`flush_err`/`safe_model_traceback` are
  1203. # bound above.
  1204. encode_plain_bound = _encode_json_plain
  1205. write_encoded_bound = channel.write_encoded
  1206. # The completion-frame builder is bound into a LOCAL here, before the
  1207. # program runs: `done = _done_with_value(...)` below sits after the program
  1208. # (which is `__main__`) may have rebound `__main__._done_with_value`, so a
  1209. # module-global lookup at call time would let a one-line rebind rewrite a
  1210. # legitimate success into an `exception`. Binding it (with its own def-time
  1211. # default-captured `_check_done_value`/`_encode_json_plain`) makes the entry
  1212. # name immune.
  1213. done_with_value_bound = _done_with_value
  1214. # The fallback primitives are bound into LOCALS here, before the program
  1215. # runs, so `send_done`'s except arm does not read module globals at call
  1216. # time. This bootstrap is `__main__`, so `__main__._os_write = boom` (or
  1217. # `__main__._FALLBACK_DONE_FRAME`, `__main__._memoryview`) would otherwise
  1218. # rebind exactly the names the fallback reads, reopening the single-line-
  1219. # rebind hole the fallback exists to close.
  1220. _os_write_local = _os_write
  1221. _memoryview_local = _memoryview
  1222. _fallback_frame_local = _FALLBACK_DONE_FRAME
  1223. def send_done(payload: dict[str, Any] | str) -> None:
  1224. try:
  1225. if _isinstance(payload, _str):
  1226. write_encoded_bound(payload)
  1227. else:
  1228. write_encoded_bound(encode_plain_bound(payload))
  1229. except: # noqa: BLE001, E722 -- a rebind must not cost the done frame; bare except avoids naming BaseException
  1230. # `encode_plain_bound`/`write_encoded_bound` are bound callables, but
  1231. # their BODIES still resolve transitive module globals at call time —
  1232. # `_encode_json_plain` reaches `_dump_scalar`/`_dump_string`/`json.dumps`,
  1233. # `write_encoded` reaches `os.write` (via the `os` module). This
  1234. # bootstrap is `__main__`, so `__main__._dump_scalar = boom` (or
  1235. # `__main__.os = ...`) makes the error-frame encode/write throw AFTER
  1236. # the `except` block, which would drop the `done` frame and downgrade a
  1237. # settled `exception` verdict to a host-side `worker-exit`. Write a fixed
  1238. # literal done frame with the LOCALLY-BOUND `_os_write_local` and
  1239. # `_memoryview_local` (captured before the program runs, so a one-line
  1240. # rebind of the module global cannot change them) so the host still gets
  1241. # a verdict. The literal is JSON-valid and newline-terminated; the lock
  1242. # is the channel's, so the write is serialized against any concurrent
  1243. # writer.
  1244. with channel._write_lock:
  1245. view = _memoryview_local(_fallback_frame_local)
  1246. while view:
  1247. view = view[_os_write_local(channel._fd, view):]
  1248. max_value_bytes = int(boot["maxValueBytes"])
  1249. done: dict[str, Any] | str
  1250. try:
  1251. # filename="<model>" keeps the source label consistent with the later
  1252. # compile(wrapped, "<model>", ...) and the runtime traceback filtering
  1253. # (safe_model_traceback drops frames whose filename is not "<model>");
  1254. # the default "<unknown>" would leak a different label into model-visible
  1255. # syntax diagnostics.
  1256. module = ast.parse(program, filename="<model>")
  1257. wrapper = ast.AsyncFunctionDef(
  1258. name="__dsh_main__",
  1259. args=ast.arguments(
  1260. posonlyargs=[], args=[], vararg=None,
  1261. kwonlyargs=[], kw_defaults=[], kwarg=None, defaults=[],
  1262. ),
  1263. body=module.body or [ast.Pass()],
  1264. decorator_list=[],
  1265. returns=None,
  1266. )
  1267. # Anchor the synthetic wrapper on the first real statement (or line 1 for
  1268. # an empty program) so fix_missing_locations does not stamp it at 0.
  1269. anchor = module.body[0] if module.body else ast.parse("pass").body[0]
  1270. ast.copy_location(wrapper, anchor)
  1271. wrapped = ast.Module(body=[wrapper], type_ignores=[])
  1272. ast.fix_missing_locations(wrapped)
  1273. # `dont_inherit=True` stops this module's `from __future__ import
  1274. # annotations` (line 14) from leaking into the program's compile: PEP 563
  1275. # would otherwise stringify the program's type annotations, changing the
  1276. # semantics of a legal program that reads `f.__annotations__` at runtime.
  1277. code = compile(wrapped, "<model>", "exec", dont_inherit=True)
  1278. exec(code, ns) # noqa: S102 -- defines __dsh_main__; executing model code is the point
  1279. value = await ns["__dsh_main__"]()
  1280. die_if_cpu_exhausted(cpu_seconds)
  1281. # Flush the log buffers BEFORE metering and framing the completion value.
  1282. # `_done_with_value` materializes the value's escaped JSON form to meter
  1283. # it and then pre-encodes the admitted value into its frame (see its
  1284. # docstring for the TOCTOU rationale) — several copies of a near-budget
  1285. # value live at once (see OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE).
  1286. # Any unflushed log pending would add its own bytes to that peak, so a
  1287. # `maxLogBytes` and a `maxValueBytes` each admitted alone by the load gate
  1288. # could together breach RLIMIT_AS. Flushing first frees the log pending so
  1289. # the value frame's peak stands alone against the address space.
  1290. flush_out()
  1291. flush_err()
  1292. done = done_with_value_bound(value, max_value_bytes)
  1293. except _BaseException as exc: # noqa: BLE001 -- report every failure to host; `_BaseException` is a pre-program local, not a rebindable module global
  1294. done = {
  1295. "type": "done",
  1296. "error": {
  1297. "kind": "exception",
  1298. # Cap the diagnostic BEFORE it crosses the wire: a program can
  1299. # raise with a gigabytes-long message, and formatting/sending
  1300. # it whole would allocate on both sides before the host's own
  1301. # cap runs. Byte-cap at maxValueBytes with the host's marker
  1302. # text so the truncated diagnostic reads identically wherever
  1303. # the cap was applied. The rendering is wrapped because the
  1304. # `done` send below sits outside this handler: a throw while
  1305. # formatting would skip it and strand the host on fd 3 until
  1306. # maxWallMs (see _make_failure_reporter).
  1307. "message": safe_model_traceback(exc, max_value_bytes),
  1308. },
  1309. }
  1310. # Flush any print output not terminated by a newline (a traceback always
  1311. # ends in one, but `print(x, end="")` or a bare write may not), so the
  1312. # final partial line is not silently dropped. The success path already
  1313. # flushed before framing the value; this is an idempotent no-op there and
  1314. # the flush the exception path needs.
  1315. #
  1316. # Guarded because `done` is ALREADY DECIDED here: on the exception path the
  1317. # handler above built it, and a flush that raises (its join/encode under
  1318. # memory pressure, after the program left a near-maxLogBytes pending and then
  1319. # allocated toward RLIMIT_AS) would skip `send_done` and downgrade a run the
  1320. # child already classified as `exception` into a host-side `worker-exit`.
  1321. # Losing the log tail is the lesser outcome, and the marker the ledger
  1322. # already pushed still reports the truncation. Same rule as
  1323. # `_make_failure_reporter`: a settled verdict must not be swallowed by the
  1324. # reporting that follows it.
  1325. for _flush in (flush_out, flush_err, _stdout_orig, _stderr_orig):
  1326. if _flush is None:
  1327. continue
  1328. try:
  1329. _flush()
  1330. except _BaseException: # noqa: BLE001 -- swallow ONLY the log tail; `done` must reach the host; `_BaseException` is a pre-program local
  1331. pass
  1332. reply_task.cancel()
  1333. send_done(done)
  1334. async def _pump_replies(
  1335. channel: ProtocolChannel,
  1336. pending: dict[int, tuple[asyncio.AbstractEventLoop, asyncio.Future[Any]]],
  1337. pending_lock: "threading.Lock",
  1338. # The frame reader is a bound method captured by _run BEFORE the program
  1339. # runs (see the create_task site), so a rebind of the class attribute cannot
  1340. # redirect it.
  1341. _read_frame: Any,
  1342. # Bound as DEFAULT ARGUMENTS so they are captured at def/import time, before
  1343. # ANY model code runs. This bootstrap IS `__main__`, so `__main__.RuntimeError
  1344. # = ...` (or `__main__._BindingRejection`, `__main__.str`, `__main__.bool`)
  1345. # as a program top-level statement would otherwise rebind the module globals
  1346. # these clauses resolve at runtime. A body-local `X = X` binding is too late:
  1347. # `_run` reaches `await __dsh_main__` (whose top-level statements run first)
  1348. # with no suspension point after `create_task`, so the model's rebind executes
  1349. # before the pump body. Defaults are evaluated in the enclosing scope at def
  1350. # time, truly before the program.
  1351. _RuntimeError: Any = RuntimeError,
  1352. _BindingRejection: Any = _BindingRejection,
  1353. _str: Any = str,
  1354. _bool: Any = bool,
  1355. ) -> None:
  1356. """Background task: read reply frames and settle pending futures.
  1357. Cancelled after ``done`` is posted. Unknown ids and post-settlement replies
  1358. are ignored (mirrors the worker backend's hostile-peer stance, though here
  1359. the host is the trusted side; the guards defend against races).
  1360. A pending Future may belong to a loop other than this pump's — the model can
  1361. call a binding from a thread running its own loop (``asyncio.run(tools.x())``).
  1362. ``asyncio.Future`` is not thread-safe, so the completion is scheduled on the
  1363. Future's OWN loop via ``call_soon_threadsafe`` rather than mutated here; a
  1364. direct ``set_result`` would never wake the waiting loop and the call would
  1365. hang to the wall clock. The ``pop`` shares ``pending_lock`` with ``dispatch``
  1366. so a reply cannot race the claim that registers its id.
  1367. """
  1368. def complete(fut: asyncio.Future[Any], ok: bool, value: Any, message: Any) -> None:
  1369. # Runs on the Future's own loop. `done()` re-checked here because
  1370. # cancellation or a duplicate reply may have settled it between the pop
  1371. # and this callback.
  1372. if fut.done():
  1373. return
  1374. if ok:
  1375. fut.set_result(value)
  1376. else:
  1377. fut.set_exception(_BindingRejection(_str(message)))
  1378. while True:
  1379. frame = await _read_frame()
  1380. if frame is None:
  1381. return
  1382. if frame.get("type") != "reply":
  1383. continue
  1384. with pending_lock:
  1385. entry = pending.pop(frame.get("id"), None)
  1386. if entry is None:
  1387. continue
  1388. loop, fut = entry
  1389. ok = _bool(frame.get("ok"))
  1390. value = frame.get("value")
  1391. message = frame.get("message")
  1392. try:
  1393. loop.call_soon_threadsafe(complete, fut, ok, value, message)
  1394. except _RuntimeError: # `_RuntimeError` is a pre-program local, not a rebindable module global
  1395. # The Future's loop has already closed — the thread that ran
  1396. # `asyncio.run(tools.x(...))` finished (its coroutine was cancelled
  1397. # or it exited) before this reply arrived, so nothing awaits the
  1398. # Future and the reply is moot. Drop it; scheduling onto a closed
  1399. # loop raises RuntimeError, and letting that escape would kill the
  1400. # pump and strand every later reply — the exact failure class this
  1401. # cross-loop delivery exists to prevent. An abandoned call's pending
  1402. # entry is not leaked: it is popped here when its reply arrives
  1403. # (dispatch's cancellation does not remove it), so stranded entries
  1404. # are bounded by the number of calls THIS run itself issued.
  1405. continue
  1406. def _encode_json_plain(value: Any) -> str:
  1407. """Encode JSON-plain data iteratively, byte-identical to compact ``json.dumps``.
  1408. ``json.dumps`` recurses one Python frame per nesting level and raises
  1409. ``RecursionError`` a few thousand levels deep, but the seam's
  1410. ``CodeJsonValue`` has no depth limit — a valid deeply nested completion or
  1411. call argument below the byte budget must cross intact (the host uses the
  1412. same iterative idiom in ``protocol.ts``). Accepts what the callers already
  1413. validated or constructed: ``None``/``bool``/``int``/finite ``float``/
  1414. ``str``, exact ``list``/``tuple``, and exact ``dict`` with ``str`` keys.
  1415. Scalar encoding delegates to ``json.dumps`` (string escaping, float repr)
  1416. so the bytes match; non-finite floats still raise ``ValueError`` exactly
  1417. like ``allow_nan=False``.
  1418. Containers are classified by EXACT type and traversed through the unbound
  1419. built-in methods rather than the instance's own: a ``dict``/``list``
  1420. subclass can override ``items``, ``keys``, ``__iter__``, ``__len__``, or
  1421. ``__getitem__``, and the validators only see the container it subclasses,
  1422. so an instance-method call here could emit different data than the walk
  1423. that metered and approved it. ``_check_done_value`` and
  1424. ``_lossless_json_violation`` reject subclasses outright, so this path only
  1425. ever sees exact containers; classifying on exact type keeps that agreement
  1426. checkable at one glance instead of resting on the caller.
  1427. """
  1428. # O(DEPTH) auxiliary space, not O(width). A container pushes ONE cursor frame
  1429. # that pulls its children one at a time and writes each into the shared buffer,
  1430. # rather than one stack entry (plus a separator marker) per child: a flat
  1431. # `[0] * 6_000_000` encodes to ~12 MB but per-element frames are ~400 MB — an
  1432. # RLIMIT_AS death on a value `_check_done_value` already admitted (it walks by
  1433. # depth as well). The output string is the only width-proportional
  1434. # allocation, and its size the caller metered within budget. `io.StringIO`
  1435. # accumulates without the intermediate `"".join(chunks)` second copy. A cursor
  1436. # frame is [kind, iterator, wrote_any]; a visit frame is (VISIT, value).
  1437. buffer = io.StringIO()
  1438. exhausted = object()
  1439. visit, list_cursor, dict_cursor = 0, 1, 2
  1440. stack: list[Any] = [(visit, value)]
  1441. while stack:
  1442. frame = stack.pop()
  1443. kind = frame[0]
  1444. if kind == list_cursor:
  1445. iterator, wrote_any = frame[1], frame[2]
  1446. child = next(iterator, exhausted)
  1447. if child is exhausted:
  1448. buffer.write("]")
  1449. continue
  1450. if wrote_any:
  1451. buffer.write(",")
  1452. else:
  1453. frame[2] = True
  1454. stack.append(frame)
  1455. stack.append((visit, child))
  1456. continue
  1457. if kind == dict_cursor:
  1458. iterator, wrote_any = frame[1], frame[2]
  1459. entry = next(iterator, exhausted)
  1460. if entry is exhausted:
  1461. buffer.write("}")
  1462. continue
  1463. key, item = entry
  1464. if wrote_any:
  1465. buffer.write(",")
  1466. else:
  1467. frame[2] = True
  1468. buffer.write(_dump_scalar(key))
  1469. buffer.write(":")
  1470. stack.append(frame)
  1471. stack.append((visit, item))
  1472. continue
  1473. current = frame[1]
  1474. current_type = type(current)
  1475. if current_type is list or current_type is tuple:
  1476. buffer.write("[")
  1477. stack.append([list_cursor, iter(current), False])
  1478. elif current_type is dict:
  1479. buffer.write("{")
  1480. stack.append([dict_cursor, iter(dict.items(current)), False])
  1481. else:
  1482. buffer.write(_dump_scalar(current))
  1483. return buffer.getvalue()
  1484. def _dump_scalar(value: Any) -> str:
  1485. """One scalar as compact JSON, byte-compatible with the host's encoder.
  1486. ``ensure_ascii=False`` keeps non-ASCII text as raw UTF-8 — the default
  1487. backslash-u escaping would make the child count ``"é"`` as 8 bytes where
  1488. the host meter (and the worker backend) count its UTF-8 JSON form as 4,
  1489. splitting the budget the two sides are supposed to share. Strings route
  1490. through :func:`_dump_string`, which restores the escaping for the one class
  1491. of character UTF-8 cannot hold. Floats route through :func:`_dump_float`
  1492. because CPython's ``repr`` and ECMAScript's Number-to-String disagree on
  1493. spelling.
  1494. Dispatch is on EXACT type, matching the validators: a ``float`` subclass
  1495. reaching :func:`_dump_float` would have its overridden ``__repr__`` read as
  1496. the number's digits, so ``F(2.5)`` whose ``__repr__`` returns ``"1.0"``
  1497. would serialize as ``1``. ``json.dumps`` then refuses any subclass by
  1498. ``TypeError`` instead of emitting a value nothing validated; the callers
  1499. reject subclasses first, so this is the encoder refusing to be the place a
  1500. validation gap turns into corrupted output.
  1501. """
  1502. if type(value) is float:
  1503. return _dump_float(value)
  1504. if type(value) is str:
  1505. return _dump_string(value)
  1506. if value is None or type(value) is bool or type(value) is int:
  1507. return json.dumps(value, ensure_ascii=False, allow_nan=False)
  1508. raise TypeError(f"unsupported type ({type(value).__name__})")
  1509. # A surrogate code unit, and an adjacent high-low pair. Python stores an astral
  1510. # character as ONE code point, so a surrogate reaching these patterns is either
  1511. # lone or half of a pair the program spelled out code unit by code unit.
  1512. _SURROGATE = re.compile("[\ud800-\udfff]")
  1513. _SURROGATE_PAIR = re.compile("[\ud800-\udbff][\udc00-\udfff]")
  1514. def _combine_surrogate_pair(match: re.Match[str]) -> str:
  1515. """Fold one spelled-out high-low pair into the astral code point it names."""
  1516. high, low = match.group(0)
  1517. return chr(0x10000 + ((ord(high) - 0xD800) << 10) + (ord(low) - 0xDC00))
  1518. def _dump_string(text: str) -> str:
  1519. """One string as compact JSON, byte-identical to the host's ``JSON.stringify``.
  1520. ``ensure_ascii=False`` cannot render a surrogate code unit: UTF-8 has no
  1521. encoding for one, so the frame write would raise and the run would strand
  1522. until the wall clock. JSON carries it as the ASCII escape ``\\ud800``, which
  1523. the host's ``JSON.parse`` reads back as the same UTF-16 code unit and its
  1524. ``JSON.stringify`` re-emits identically — so the shared seam
  1525. (``CodeJsonValue``, ``snapshotJsonValue``, the worker backend) keeps a
  1526. lone-surrogate string instead of failing the value. An adjacent high-low
  1527. pair is folded into its astral code point FIRST: the host holds strings as
  1528. UTF-16, where those two code units and the single character are the same
  1529. string, and the raw 4-byte form is what the host would emit — escaping the
  1530. halves separately would charge 12 bytes against a budget the host meters at
  1531. 4. Every remaining surrogate is lone and becomes six ASCII bytes, matching
  1532. the host exactly.
  1533. @param text: the string to encode.
  1534. @return: its compact JSON form, always UTF-8-encodable.
  1535. """
  1536. rendered = json.dumps(text, ensure_ascii=False)
  1537. if _SURROGATE.search(rendered) is None:
  1538. return rendered
  1539. return _SURROGATE.sub(
  1540. lambda match: "\\u%04x" % ord(match.group(0)),
  1541. _SURROGATE_PAIR.sub(_combine_surrogate_pair, rendered),
  1542. )
  1543. # How many bytes each byte that needs escaping adds beyond its raw self, as a
  1544. # ready-made (byte, surcharge) list so :func:`_json_string_cost` walks no
  1545. # branches per pass. ``"`` and ``\\`` take a one-character prefix; the five C0
  1546. # controls with a shorthand (``\\b\\f\\n\\r\\t``) likewise; every other C0
  1547. # control becomes a six-character ``\\uXXXX``.
  1548. _JSON_ESCAPE_SURCHARGES = [
  1549. (bytes((byte,)), 1 if byte in b'"\\\b\f\n\r\t' else 5)
  1550. for byte in [*range(0x20), ord('"'), ord("\\")]
  1551. ]
  1552. # Per-byte JSON-string serialized cost (the byte itself plus its escape
  1553. # surcharge), indexed by byte value. Lets :func:`_cap_message` accumulate the
  1554. # serialized cost of a growing prefix in one O(1) step per byte without building
  1555. # the escaped form. A non-ASCII byte stays raw (cost 1); a C0 control or ``"``/
  1556. # ``\\`` carries its surcharge from :data:`_JSON_ESCAPE_SURCHARGES`.
  1557. _JSON_BYTE_COST = [1] * 256
  1558. for _escaped_byte, _surcharge in _JSON_ESCAPE_SURCHARGES:
  1559. _JSON_BYTE_COST[_escaped_byte[0]] = 1 + _surcharge
  1560. def _json_str_cost(text: str) -> int:
  1561. """Byte length of ``text``'s JSON string form, WITHOUT building that form.
  1562. The str-side twin of :func:`_json_string_cost`, for the completion-value
  1563. meter. Measuring by materializing ``_dump_string(text).encode()`` allocates
  1564. the escaped copy plus its encode -- for a NUL-heavy string that is ~6x the
  1565. original each, so metering a value the budget would have REJECTED could
  1566. itself breach ``RLIMIT_AS`` and report ``exception`` where the contract
  1567. promises ``output-limit``.
  1568. The common case encodes once (~1x, well inside the load gate's envelope) and
  1569. counts escapes with the same C-level passes :func:`_json_string_cost` uses.
  1570. A string carrying surrogate code units has no UTF-8 form at all, so it takes
  1571. the exact path :func:`_dump_string` defines: fold each spelled-out high-low
  1572. pair into its astral character first (the host meters that as its raw 4-byte
  1573. form), then charge six ASCII bytes for every surviving lone surrogate and
  1574. count the rest from its encodable remainder.
  1575. @param text: the string to measure.
  1576. @return: the byte length of its JSON string form, quotes included.
  1577. """
  1578. try:
  1579. return _json_string_cost(text.encode("utf-8"))
  1580. except UnicodeEncodeError:
  1581. pass
  1582. folded = _SURROGATE_PAIR.sub(_combine_surrogate_pair, text)
  1583. # Remove the lone surrogates first, then count them as the length
  1584. # difference: `_SURROGATE.findall(folded)` materialized one single-character
  1585. # string PER surrogate, so a surrogate-dense value near the budget
  1586. # (millions of lone surrogates, each serializing to six bytes) allocated
  1587. # millions of objects before the meter returned -- an RLIMIT_AS death
  1588. # surfacing as `exception` instead of the promised `output-limit`. After
  1589. # pair-combining, every remaining surrogate is lone and exactly one code
  1590. # point, so the removed length is the count, and the `without` string is
  1591. # needed for the meter anyway.
  1592. without = _SURROGATE.sub("", folded)
  1593. lone = len(folded) - len(without)
  1594. # Six ASCII bytes per lone surrogate; the remainder is ordinary text whose
  1595. # own quotes are dropped here because the outer call adds them once.
  1596. return _json_string_cost(without.encode("utf-8")) + lone * 6
  1597. def _json_string_cost(raw: bytes) -> int:
  1598. """UTF-8 byte length of one string's JSON form, WITHOUT building that form.
  1599. Used by :class:`LogBuffer` to charge a log entry what it will actually cost
  1600. on the wire. Building ``json.dumps(text)`` to measure it would allocate a
  1601. second copy up to six times the original — the very allocation the ledger's
  1602. cheap pre-check exists to avoid, and enough to breach ``RLIMIT_AS`` on a
  1603. large control-heavy line. Counts exactly what :func:`_dump_scalar`'s
  1604. ``ensure_ascii=False`` output holds: the two quotes, each escaped byte's
  1605. surcharge from :data:`_JSON_ESCAPE_SURCHARGES`, and the raw bytes themselves
  1606. (non-ASCII stays raw, so its UTF-8 length already counts). Uses a fixed
  1607. number of C-level ``count`` passes — allocating nothing, unlike a
  1608. ``translate`` filter — because the caller admits up to ~4x the remaining
  1609. budget of bytes here and a per-byte Python loop over it would cost more than
  1610. the encode being avoided.
  1611. @param raw: the entry's UTF-8 bytes.
  1612. @return: the byte length of its JSON string form, quotes included.
  1613. """
  1614. extra = 0
  1615. for byte, surcharge in _JSON_ESCAPE_SURCHARGES:
  1616. extra += raw.count(byte) * surcharge
  1617. return len(raw) + 2 + extra
  1618. def _dump_float(value: float) -> str:
  1619. """One finite float in ECMAScript ``Number::toString`` spelling.
  1620. CPython's ``repr`` and the host's ``String(number)`` name the same double
  1621. differently: ``1.0`` is ``"1.0"`` here but ``"1"`` there, ``1e-07`` pads the
  1622. exponent the host writes as ``1e-7``, and ``1e+21``/``2**60`` differ again.
  1623. Since the child meters the completion value against ``maxValueBytes`` and
  1624. the host re-meters the frame it parses, any spelling difference splits the
  1625. shared budget: ``return 1.0`` under ``maxValueBytes: 1`` used to be reported
  1626. as ``output-limit`` by the child while the host would have counted the
  1627. one-byte ``1`` it actually receives. Both sides also emit these bytes (the
  1628. child through :func:`_encode_json_plain`, the host through
  1629. ``encodeJsonPlain``), so the fix has to be in the shared speller, not in the
  1630. meter.
  1631. Implements ECMA-262 ``Number::toString`` radix 10 directly: ``repr``
  1632. already yields the shortest round-tripping decimal digits, and ``Decimal``
  1633. splits them into the significand ``s`` (``digits``, ``k`` of them) and
  1634. decimal exponent ``n`` the spec's cases select on. The integral values above
  1635. the JS safe range take the host's BigInt branch, whose exact digits differ
  1636. from the shortest-round-trip form (``2**60`` prints ``...846976``, not
  1637. ``...847000``).
  1638. """
  1639. if value != value or value in (float("inf"), float("-inf")):
  1640. # json.dumps(allow_nan=False) raises the same way; the callers reject
  1641. # non-finite floats before metering, so this is unreachable defense.
  1642. raise ValueError("Out of range float values are not JSON compliant")
  1643. if value == 0.0:
  1644. # Covers -0.0 too; callers reject it as non-lossless before this point.
  1645. return "0"
  1646. if value < 0:
  1647. return "-" + _dump_float(-value)
  1648. if value.is_integer() and value > float(2**53 - 1):
  1649. # The host's BigInt branch: exact digits, not shortest-round-trip.
  1650. return str(int(value))
  1651. parts = Decimal(repr(value)).normalize(context=_FLOAT_CONTEXT).as_tuple()
  1652. digits = "".join(str(digit) for digit in parts.digits)
  1653. k = len(digits)
  1654. n = parts.exponent + k
  1655. if k <= n <= 21:
  1656. return digits + "0" * (n - k)
  1657. if 0 < n <= 21:
  1658. return digits[:n] + "." + digits[n:]
  1659. if -6 < n <= 0:
  1660. return "0." + "0" * -n + digits
  1661. exponent = ("+" if n - 1 >= 0 else "-") + str(abs(n - 1))
  1662. return (digits if k == 1 else digits[0] + "." + digits[1:]) + "e" + exponent
  1663. def _check_done_value(value: Any, max_bytes: int):
  1664. """Meter a completion value's JSON byte size AND validate its lossless-JSON
  1665. shape in one bounded post-order walk; return ``None`` when it passes.
  1666. Folds what was formerly a losslessness walk followed by a separate byte
  1667. meter into one pass. Running the losslessness walk first materialized one
  1668. traversal tuple per element before any size cap: ``return [0] * 2000000``
  1669. under ``maxValueBytes: 64`` allocated millions of frames (an RLIMIT_AS
  1670. death) before the meter could reject it. Folding the byte bound into the
  1671. walk rejects over-budget BEFORE enqueuing a container's children — every
  1672. element is at least one JSON byte — so the walk stays O(cap). Same
  1673. JS-double-exact integer boundary, cycle detection (a leave marker pops each
  1674. container off ``on_path``), and type rejections as
  1675. :func:`_lossless_json_violation`, and the same byte accounting as
  1676. :func:`_encode_json_plain`. :func:`_lossless_json_violation` stays for the
  1677. binding-argument path, which carries no size cap.
  1678. EVERY type here is matched EXACTLY, containers and scalars alike, so a
  1679. subclass is rejected as an unsupported type rather than admitted by
  1680. ``isinstance``. A subclass can override the operators and methods this walk
  1681. and the encoder call, and they need not agree: a populated ``dict``
  1682. subclass whose ``items()`` returns ``[]`` would meter as ``{}``; a ``float``
  1683. subclass overriding ``__repr__`` passes the non-finite and negative-zero
  1684. checks by its real value but serializes as whatever the override says, since
  1685. :func:`_dump_float` reads ``repr``; an ``int`` subclass overriding ``__gt__``
  1686. and ``__lt__`` slips past the JS-safe-range bound while ``json.dumps``
  1687. emits its true C-level digits, so ``2**53 + 1`` reaches the host as
  1688. ``...992``; a ``str`` subclass overriding ``__len__`` returns 0 from the
  1689. pre-encode lower bound and admits an arbitrarily large string. In each case
  1690. the value the host receives differs from the one this walk approved. The
  1691. worker backend rejects the equivalent shapes by prototype identity and
  1692. ``typeof`` (``hasPlainObjectPrototype`` in ``worker-json.ts``); a ``bool``
  1693. is checked before ``int`` because it is an ``int`` subclass that IS
  1694. lossless JSON.
  1695. Returns ``("invalid-output", message)`` for a non-lossless value,
  1696. ``("output-limit", message)`` once the size crosses ``max_bytes``, or
  1697. ``None`` when the value is lossless JSON within budget.
  1698. Metering and validation interleave in this single traversal: each member is
  1699. costed the moment it is visited, and it is rejected the moment it trips
  1700. either check. A value that holds BOTH an over-budget member and an
  1701. invalid-typed member therefore resolves to whichever tripped FIRST in
  1702. visit order — both are rejects, and neither kind claims priority over the
  1703. other, so that first-trip order is not part of the seam contract; the
  1704. host side independently re-measures the value it receives.
  1705. """
  1706. js_safe = 2**53 - 1
  1707. def invalid(reason: str):
  1708. return ("invalid-output", f"program completion must be lossless JSON ({reason})")
  1709. over_budget = ("output-limit", f"completion value exceeded {max_bytes} bytes")
  1710. total = 0
  1711. on_path: set[int] = set()
  1712. # The walk uses O(DEPTH) space, not O(width). A container pushes ONE cursor
  1713. # frame that pulls its children one at a time, rather than one traversal
  1714. # frame per child: a flat `[0] * 6_000_000` serializes to ~12 MB (well within
  1715. # a modest budget) but one tuple per element is ~380 MB — an RLIMIT_AS death
  1716. # on a value the byte meter would admit, the very inversion this meter exists
  1717. # to prevent. A cursor frame is (kind, container, iterator); a visit frame is
  1718. # (VISIT, value, None). The upfront structural bound still rejects a wide
  1719. # forgery before any iteration begins.
  1720. exhausted = object()
  1721. visit, list_cursor, dict_cursor = 0, 1, 2
  1722. stack: list[tuple[int, Any, Any]] = [(visit, value, None)]
  1723. while stack:
  1724. frame = stack.pop()
  1725. kind = frame[0]
  1726. if kind == list_cursor:
  1727. container, iterator = frame[1], frame[2]
  1728. child = next(iterator, exhausted)
  1729. if child is exhausted:
  1730. on_path.discard(id(container))
  1731. continue
  1732. # Resume this cursor after the child is fully walked; the child goes
  1733. # on top so it is visited next (order does not affect the byte total).
  1734. stack.append(frame)
  1735. stack.append((visit, child, None))
  1736. continue
  1737. if kind == dict_cursor:
  1738. container, iterator = frame[1], frame[2]
  1739. entry = next(iterator, exhausted)
  1740. if entry is exhausted:
  1741. on_path.discard(id(container))
  1742. continue
  1743. key, item = entry
  1744. # Only an EXACT str key survives: bool and int coerce or raise, and a
  1745. # str SUBCLASS can override the ``__len__`` the bound below reads while
  1746. # the encoder emits its real characters.
  1747. if type(key) is not str:
  1748. return invalid(f"non-string dict key ({type(key).__name__})")
  1749. # The same string lower bound, before escaping the key.
  1750. if total + len(key) + 3 > max_bytes:
  1751. return over_budget
  1752. # Same counting rule as the string branch: a control-heavy KEY
  1753. # expands just as far, and `_dump_scalar` on a str is `_dump_string`.
  1754. total += _json_str_cost(key) + 1
  1755. if total > max_bytes:
  1756. return over_budget
  1757. stack.append(frame)
  1758. stack.append((visit, item, None))
  1759. continue
  1760. current = frame[1]
  1761. if current is None or type(current) is bool:
  1762. total += len(_dump_scalar(current).encode("utf-8"))
  1763. elif type(current) is str:
  1764. # Lower-bound BEFORE materializing the escaped form: every character
  1765. # is at least one UTF-8 byte plus the two quotes, so a huge or
  1766. # control-heavy string (whose escaped copy expands severalfold) is
  1767. # rejected without allocating that copy.
  1768. if total + len(current) + 2 > max_bytes:
  1769. return over_budget
  1770. # A lone surrogate has no UTF-8 form but a lossless JSON one — the
  1771. # ASCII ``\uXXXX`` escape :func:`_dump_string` emits — so it is
  1772. # metered, not rejected, matching the shared seam. Metered by
  1773. # COUNTING, not by building the escaped form: that copy plus its
  1774. # encode is ~6x the original for a control-heavy string, so measuring
  1775. # a value the budget rejects could breach RLIMIT_AS and surface as
  1776. # `exception` instead of the promised `output-limit`.
  1777. total += _json_str_cost(current)
  1778. elif type(current) is int:
  1779. # The canonical boundary accepts every JS-double-exact value: an int
  1780. # outside +-2**53-1 is fine IFF the double round-trip is exact.
  1781. if current > js_safe or current < -js_safe:
  1782. try:
  1783. exact = int(float(current)) == current
  1784. except OverflowError:
  1785. exact = False
  1786. if not exact:
  1787. return invalid("integer not exactly representable as a JavaScript number")
  1788. total += len(_dump_scalar(current).encode("utf-8"))
  1789. elif type(current) is float:
  1790. if current != current or current in (float("inf"), float("-inf")):
  1791. return invalid("non-finite float")
  1792. # JSON turns -0.0 into a sign the host parses back to JS -0; the
  1793. # canonical boundary rejects it, so this side must too.
  1794. if current == 0.0 and math.copysign(1.0, current) < 0:
  1795. return invalid("negative zero")
  1796. total += len(_dump_scalar(current).encode("utf-8"))
  1797. elif type(current) is list:
  1798. if id(current) in on_path:
  1799. return invalid("circular reference")
  1800. count = len(current)
  1801. total += 2 + (count - 1 if count > 1 else 0)
  1802. # Reject over-budget BEFORE iterating: every element serializes to at
  1803. # least one byte, so a wide flat forgery fails here without pulling a
  1804. # single child.
  1805. if total + count > max_bytes:
  1806. return over_budget
  1807. on_path.add(id(current))
  1808. stack.append((list_cursor, current, iter(current)))
  1809. elif type(current) is dict:
  1810. if id(current) in on_path:
  1811. return invalid("circular reference")
  1812. # ``len`` without materializing ``current.items()``: that list
  1813. # allocates one tuple per member before the bound below could run,
  1814. # recreating the spike the bound exists to stop.
  1815. count = len(current)
  1816. total += 2 + (count - 1 if count > 1 else 0)
  1817. # Same pre-iterate bound: each entry contributes a quoted key
  1818. # (>= 2 bytes), a colon, and a >= 1-byte value. ``iter`` on the items
  1819. # view is O(1); the cursor meters each key as it is pulled.
  1820. if total + count * 4 > max_bytes:
  1821. return over_budget
  1822. on_path.add(id(current))
  1823. stack.append((dict_cursor, current, iter(current.items())))
  1824. else:
  1825. # tuple, set, or any other type: not round-trippable JSON.
  1826. return invalid(f"unsupported type ({type(current).__name__})")
  1827. if total > max_bytes:
  1828. return over_budget
  1829. return None
  1830. def _lossless_json_violation(value: Any) -> str | None:
  1831. """Return why ``value`` is not lossless JSON, or ``None`` when it is.
  1832. ``json.dumps`` succeeding is NOT proof of losslessness: it coerces a
  1833. non-string ``dict`` key to its string form (``{1: "a", "1": "b"}`` collapses
  1834. to one key, silently dropping data), emits non-standard ``NaN``/``Infinity``
  1835. tokens without ``allow_nan=False``, and accepts integers outside JavaScript's
  1836. safe range (``9007199254740993`` becomes ``...992`` once the host parses the
  1837. frame into a JS number). Validate the shape up front so a coercive or lossy
  1838. value fails as ``invalid-output`` instead of round-tripping to something the
  1839. program did not compute. Iterative so deep nesting cannot overflow the stack,
  1840. and it tracks the container ancestry on the current path so a cyclic value is
  1841. reported at once rather than spinning until the CPU budget. Only JSON-plain
  1842. types survive: ``None``/``bool``/JS-safe ``int``/finite ``float``/``str``,
  1843. exact ``list``, and exact ``dict`` with ``str`` keys. Every type matches
  1844. EXACTLY, containers and scalars alike, for the reason
  1845. :func:`_check_done_value` documents: a subclass can override the operators
  1846. and methods a traversal calls, so an ``isinstance`` admission here would
  1847. approve one shape and let the encoder emit another.
  1848. """
  1849. # The canonical boundary accepts every JS-double-exact value: an int
  1850. # outside +-2**53-1 is fine IFF the double round-trip is exact (2**53 or
  1851. # 2**60 survive; 2**53+1 rounds), matching the worker backend.
  1852. js_safe = 2**53 - 1
  1853. # Post-order walk with an explicit "leave" marker: a container's id is added
  1854. # to `on_path` when entered and removed when left, so a back-edge to an
  1855. # ancestor (a cycle) is detected without rejecting a legitimately shared
  1856. # acyclic subtree.
  1857. on_path: set[int] = set()
  1858. # O(DEPTH) auxiliary space, not O(width), for the reason
  1859. # :func:`_check_done_value` documents: this walk runs in ``dispatch`` on
  1860. # MODEL-CONSTRUCTED binding arguments, which no child-side byte budget
  1861. # bounds first (the frame ceiling is the host's, and it applies after this
  1862. # returns). Enqueueing one frame per member would let a legitimate
  1863. # ``[0] * 6_000_000`` argument -- ~17 MB of JSON -- allocate ~366 MB of
  1864. # traversal tuples and die as the program's own MemoryError instead of
  1865. # round-tripping. A container therefore pushes ONE cursor frame holding its
  1866. # iterator; children are pulled one at a time.
  1867. exhausted = object()
  1868. visit, container_cursor = 0, 1
  1869. # A visit frame is (visit, value); a cursor frame is (cursor, container, iterator).
  1870. stack: list[tuple[int, Any, Any]] = [(visit, value, None)]
  1871. while stack:
  1872. kind = stack[-1][0]
  1873. if kind == container_cursor:
  1874. _, container, iterator = stack[-1]
  1875. child = next(iterator, exhausted)
  1876. if child is exhausted:
  1877. # Leaving the container: it is no longer on the current path, so
  1878. # a legitimately shared acyclic subtree is not mistaken for a cycle.
  1879. on_path.discard(id(container))
  1880. stack.pop()
  1881. continue
  1882. if type(container) is dict:
  1883. # The dict cursor yields (key, value): check the key as it is
  1884. # pulled. Only an EXACT str key survives -- int, float, None, and
  1885. # tuple keys coerce or raise, and a str subclass can carry
  1886. # overrides the encoder does not honor.
  1887. key, child = child
  1888. if type(key) is not str:
  1889. return f"non-string dict key ({type(key).__name__})"
  1890. stack.append((visit, child, None))
  1891. continue
  1892. _, current, _unused = stack.pop()
  1893. if current is None or type(current) is bool:
  1894. continue
  1895. if type(current) is str:
  1896. # Every string is lossless JSON. A lone surrogate has no UTF-8 form,
  1897. # but JSON carries the code unit as its ASCII ``\uXXXX`` escape and
  1898. # :func:`_dump_string` emits exactly that, so the host receives the
  1899. # same code unit the program passed — the same acceptance
  1900. # ``CodeJsonValue``, ``snapshotJsonValue``, and the worker backend
  1901. # already give it.
  1902. continue
  1903. if type(current) is int:
  1904. if current > js_safe or current < -js_safe:
  1905. try:
  1906. exact = int(float(current)) == current
  1907. except OverflowError:
  1908. exact = False
  1909. if not exact:
  1910. return "integer not exactly representable as a JavaScript number"
  1911. continue
  1912. if type(current) is float:
  1913. if current != current or current in (float("inf"), float("-inf")):
  1914. return "non-finite float"
  1915. # JSON serialization turns -0.0 into 0 (or "-0.0" text that the
  1916. # host parses to JS -0), silently changing the sign bit either
  1917. # way; the repository's canonical lossless-JSON boundary and the
  1918. # worker backend both reject it, so this side must too.
  1919. if current == 0.0 and math.copysign(1.0, current) < 0:
  1920. return "negative zero"
  1921. continue
  1922. if type(current) is list or type(current) is dict:
  1923. if id(current) in on_path:
  1924. return "circular reference"
  1925. on_path.add(id(current))
  1926. if type(current) is dict:
  1927. # Keys are checked as the cursor pulls each entry, not in a
  1928. # separate pass: ``current.values()`` would need a second walk,
  1929. # and materializing ``items()`` up front allocates one tuple per
  1930. # member -- the very spike the cursor removes.
  1931. stack.append((container_cursor, current, iter(current.items())))
  1932. else:
  1933. stack.append((container_cursor, current, iter(current)))
  1934. continue
  1935. return f"unsupported type ({type(current).__name__})"
  1936. return None
  1937. def _make_cpu_enforcer() -> Any:
  1938. """Build the CPU post-check over closure-held primitives.
  1939. This bootstrap IS ``__main__``, so model code can reach every one of its
  1940. module globals: ``import __main__; __main__._X = ...`` rebinds the name the
  1941. enforcement would otherwise read at call time, which a plain module-level
  1942. function plus module-level captures made a one-line defeat. The primitives
  1943. therefore live in this factory's locals, which become closure cells of the
  1944. returned function, and :func:`_run` binds the returned function into a
  1945. local of its own frame BEFORE executing the program, so no assignment to
  1946. ``__main__`` changes which callable runs or what it calls. Capture happens
  1947. at import time, before model code runs, so the captured
  1948. ``resource.getrusage``/``signal.signal``/``os.kill`` are the real builtins.
  1949. This raises the cost of defeating the check; it does not make it
  1950. unreachable, and nothing in-process could. A cell is writable through
  1951. ``fn.__closure__[i].cell_contents``, and ``sys._getframe`` walks to
  1952. :func:`_run`'s frame and reads its locals, so a program determined to
  1953. tamper still can — consistent with this backend's documented posture, where
  1954. the in-process interpreter is containment rather than a security boundary
  1955. (§Trust posture in the PTC mode Agent Note). The bounds that model code cannot
  1956. forge are outside the interpreter: the RLIMIT_CPU HARD limit at
  1957. ``cpuSeconds + 1``, whose SIGKILL is undeliverable to a handler and
  1958. unraisable by a process that cannot raise its own hard limit, and the
  1959. host's wall-clock ceiling. This check exists to convert the two cases those
  1960. miss — a program that traps SIGXCPU and settles inside the soft-to-hard
  1961. gap, and a program that spends the budget in DESCENDANTS the kernel never
  1962. charged to this process — from a reported SUCCESS into the same `timeout`
  1963. an untrapped program gets.
  1964. @returns The one-argument enforcement callable, taking `cpuSeconds`.
  1965. """
  1966. getrusage = resource.getrusage
  1967. rusage_self = resource.RUSAGE_SELF
  1968. rusage_children = resource.RUSAGE_CHILDREN
  1969. set_signal = signal.signal
  1970. sig_dfl = signal.SIG_DFL
  1971. sigxcpu = signal.SIGXCPU
  1972. kill = os.kill
  1973. getpid = os.getpid
  1974. # SIGXCPU unmasking primitives for the re-raise below: a program can mask
  1975. # the signal and return past the soft limit, so the re-delivered signal
  1976. # must be unblocked first. Captured here (import time) so a rebind cannot
  1977. # defeat them. The ``getattr``/``None`` guard is defensive against a
  1978. # stripped CPython build (the host refuses win32 at construction, so every
  1979. # platform this backend actually starts on has ``pthread_sigmask``).
  1980. pthread_sigmask = getattr(signal, "pthread_sigmask", None)
  1981. sig_unblock = getattr(signal, "SIG_UNBLOCK", None)
  1982. def die_if_cpu_exhausted(cpu_seconds: int) -> None:
  1983. """Die by re-delivered SIGXCPU when the CPU budget is already spent.
  1984. Two cases reach here as a would-be SUCCESS. A model program can trap
  1985. SIGXCPU and return during the one-second soft-to-hard gap. And
  1986. ``RLIMIT_CPU`` is PER-PROCESS, inherited fresh by every child, so a
  1987. program calling ``subprocess`` or ``os.fork`` multiplies the run's CPU
  1988. budget by the number of descendants it starts: measured with
  1989. ``cpuSeconds: 1``, two sequential busy children burned 2.0
  1990. CPU-seconds and the parent, which had accrued almost no CPU of its own
  1991. while blocked in ``subprocess.wait``, still returned a completion.
  1992. The meter is therefore ``RUSAGE_SELF + RUSAGE_CHILDREN``, the kernel's
  1993. own aggregate, which accumulates the CPU of every REAPED descendant
  1994. (grandchildren included, verified).
  1995. ``getrusage`` is the kernel's own meter (unforgeable from model code),
  1996. and dying by SIGXCPU with the default disposition restored gives the
  1997. host the same kernel-authoritative close signal as the untrapped soft
  1998. limit — classified as `timeout`, after which the host's process-group
  1999. SIGTERM/SIGKILL teardown reaches any surviving descendants. Runs AFTER
  2000. the model program settled, so a program can re-trap SIGXCPU between
  2001. this SIG_DFL and the kill only by running more code, which it no longer
  2002. does. A program that tampers with this callable instead (see
  2003. :func:`_make_cpu_enforcer` on why in-process state cannot be hidden)
  2004. buys at most the remaining soft-to-hard gap: one more CPU second, after
  2005. which the hard limit's SIGKILL lands with no handler possible.
  2006. Checking at settle time rather than sampling mid-run is deliberate:
  2007. both mid-run designs perturb the run they measure. A sampling thread
  2008. cost 72 MiB of virtual address space in the child (8 MiB stack plus a
  2009. 64 MiB glibc per-thread malloc arena reservation; measured 30.23 MiB of
  2010. mappings without it against 102.37 MiB with it), and ``RLIMIT_AS``
  2011. counts reserved space, so it silently shrank every run's
  2012. `addressSpaceMb`. A ``SIGALRM`` interval timer costs no mappings but
  2013. makes the program's own syscalls return short under PEP 475 — measured
  2014. a 64 MiB ``os.write`` returning 65536 — which corrupts fd-3 framing.
  2015. The cost of checking only at settle time is that a descendant's CPU is
  2016. detected after it is spent, not while it runs; the host's wall-clock
  2017. ceiling bounds that interval, and a program that never reaps its child
  2018. is bounded by the wall clock alone, since ``RUSAGE_CHILDREN`` counts
  2019. only reaped descendants (verified: a still-running child contributes
  2020. 0.0).
  2021. @param cpu_seconds The `cpuSeconds` budget the soft RLIMIT_CPU used.
  2022. """
  2023. own = getrusage(rusage_self)
  2024. kids = getrusage(rusage_children)
  2025. spent = own.ru_utime + own.ru_stime + kids.ru_utime + kids.ru_stime
  2026. if spent >= cpu_seconds:
  2027. # A program can mask SIGXCPU (``pthread_sigmask(SIG_BLOCK, ...)``),
  2028. # burn past the soft limit, and return during the soft-to-hard gap;
  2029. # the re-delivered SIGXCPU below would then stay PENDING and the
  2030. # child would exit normally with a success result. Restore the
  2031. # default disposition BEFORE unblocking: a program that installed a
  2032. # custom handler AND masked the signal has that pending handler run
  2033. # the moment the signal is unblocked (CPython delivers it at the next
  2034. # eval-breaker checkpoint in model code), and it could re-mask or
  2035. # raise — so the disposition must already be SIG_DFL when the signal
  2036. # is released. With SIG_DFL restored first, the pending signal kills
  2037. # the process inside the kernel with no bytecode window; the ``kill``
  2038. # below is the fallback for the never-pending case. ``pthread_sigmask``
  2039. # is ``None``-guarded defensively (every platform this backend starts
  2040. # on has it; the host refuses win32 at construction).
  2041. set_signal(sigxcpu, sig_dfl)
  2042. if pthread_sigmask is not None:
  2043. pthread_sigmask(sig_unblock, (sigxcpu,))
  2044. kill(getpid(), sigxcpu)
  2045. return die_if_cpu_exhausted
  2046. _DIE_IF_CPU_EXHAUSTED = _make_cpu_enforcer()
  2047. _TRUNCATION_MARKER = "… [truncated]"
  2048. # The marker's own UTF-8 size, reserved out of the cap rather than added on top
  2049. # of it. Byte-identical to the host's TRUNCATION_MARKER_BYTES; the ellipsis is
  2050. # three bytes, so this is 15, not the string's 13 characters.
  2051. _TRUNCATION_MARKER_BYTES = len(_TRUNCATION_MARKER.encode("utf-8"))
  2052. def _cap_message(message: str, max_bytes: int) -> str:
  2053. """Cap a diagnostic by its SERIALIZED cost, appending the host's marker.
  2054. Metered by the JSON-string cost the ``done`` frame will actually carry, not
  2055. by raw UTF-8 length: the message crosses fd 3 inside a JSON frame where
  2056. control characters escape up to sixfold (a NUL is one raw byte but six as
  2057. ``\\u0000``), so a raw-length cap of ``maxValueBytes`` could serialize to
  2058. roughly six times that and breach the 64 MiB frame parse cap — the silent
  2059. ``worker-exit`` inversion the load-time cap check exists to prevent, and a
  2060. several-hundred-MiB escape allocation besides. The seam's load bound admits
  2061. ``maxValueBytes`` up to ``parse-cap - envelope`` on the premise that both the
  2062. completion value and the diagnostic are metered in serialized bytes, so this
  2063. honors that premise for the diagnostic.
  2064. Encoded with ``errors="replace"`` first: a model exception message can
  2065. contain an unpaired surrogate (``raise Exception("\\ud800")``), and a strict
  2066. encode would throw while BUILDING the failure frame — the run would then
  2067. strand until the wall clock instead of reporting the exception. The marker's
  2068. serialized cost comes OUT of ``max_bytes``, so the returned string's own
  2069. frame form honors the cap; the host meters the same field again on arrival.
  2070. A ``max_bytes`` below the marker's cost yields the marker alone.
  2071. This is the PRODUCING-side cap. The host's receive-side ``capMessage``
  2072. (``src/index.ts``) bills the same field by RAW bytes instead, because its
  2073. output goes into ``CodeRunResult.error.message`` and never re-crosses a
  2074. frame-bounded channel — see that function's JSDoc for the split.
  2075. """
  2076. raw = message.encode("utf-8", errors="replace")
  2077. if _json_string_cost(raw) <= max_bytes:
  2078. return raw.decode("utf-8")
  2079. # Truncating: the result is `prefix + marker`, whose serialized cost is
  2080. # `2 (quotes) + sum(prefix byte costs) + marker cost`. The marker is
  2081. # escape-free, so its cost is its UTF-8 length. Reserve that and the quotes,
  2082. # then take the longest raw prefix whose accumulated per-byte cost fits.
  2083. # `_JSON_BYTE_COST` is per-byte and additive, so the scan is exact and walks
  2084. # at most a budget's worth of bytes, allocating nothing (unlike building the
  2085. # escaped form). `max(0, ...)` handles a `max_bytes` below the marker's own
  2086. # cost, yielding the marker alone.
  2087. content_budget = max(0, max_bytes - 2 - _TRUNCATION_MARKER_BYTES)
  2088. cost = 0
  2089. end = 0
  2090. for end in range(len(raw)):
  2091. cost += _JSON_BYTE_COST[raw[end]]
  2092. if cost > content_budget:
  2093. break
  2094. else:
  2095. end = len(raw)
  2096. # Drop a trailing partial UTF-8 sequence the slice may have cut (continuation
  2097. # bytes are 0b10xxxxxx); `errors="ignore"` renders the clean prefix.
  2098. return raw[:end].decode("utf-8", errors="ignore") + _TRUNCATION_MARKER
  2099. # Fixed safety/liveness bound, not a tunable: a model can raise an exception
  2100. # with an arbitrarily deep __cause__/__context__ chain, and both the rendering
  2101. # walk and format() are linear in chain length. Capping how many links get
  2102. # RENDERED keeps traceback formatting from consuming the whole wall budget.
  2103. # 100 links is far beyond any legible human traceback.
  2104. _MAX_TRACEBACK_CHAIN = 100
  2105. # Diagnostic used when rendering the failure itself fails. Built from a fixed
  2106. # literal plus the exception CLASS name, never from the exception's own str.
  2107. _UNRENDERABLE_DIAGNOSTIC = "<diagnostic rendering failed>"
  2108. def _model_traceback(exc: BaseException, max_bytes: int) -> str:
  2109. """Format a model-program failure with only the MODEL's own frames.
  2110. Bootstrap frames carry host-absolute paths — meaningless to the model and
  2111. unstable across machines, so transcripts pinning them cannot replay. They
  2112. appear not only as a leading prefix (the bootstrap's ``exec``/``await``)
  2113. but also interleaved and trailing: an uncaught binding rejection re-raised
  2114. by ``dispatch`` puts bootstrap frames AFTER the model's, and chained
  2115. ``__cause__``/``__context__`` exceptions carry their own stacks. Filter
  2116. every non-``<model>`` frame across the whole chain rather than trimming a
  2117. prefix. A failure with no model frame anywhere (e.g. a SyntaxError raised
  2118. by ``compile``) keeps the standard exception-only rendering.
  2119. Rendering is bounded to ``_MAX_TRACEBACK_CHAIN`` links, cut on the
  2120. ``TracebackException`` COPY, and a marker line announces the truncation.
  2121. Nothing here touches the live exception: an exception class overriding
  2122. ``__setattr__`` would run MODEL code from inside the caller's failure
  2123. handler, and a throw there costs the ``done`` frame (see
  2124. ``_safe_model_traceback``). ``TracebackException`` instances hold no such
  2125. hooks, so clearing their links runs no model code. The walk is iterative,
  2126. so a deep chain cannot overflow the recursion limit.
  2127. ``from_exception`` still copies the WHOLE live chain, at a higher per-link
  2128. cost than building it took. That is bounded by the child's ``RLIMIT_AS``:
  2129. the model must materialize every link (exception object plus traceback)
  2130. before raising, so a chain long enough for the copy to matter is already
  2131. near the address-space cap, and a ``MemoryError`` in the copy lands in the
  2132. caller's fallback rather than stranding the run.
  2133. """
  2134. te = traceback.TracebackException.from_exception(exc)
  2135. # One iterative pass over the copy does both jobs: keep only <model> frames
  2136. # on every linked exception and group member, and cut the chain at the cap.
  2137. found = False
  2138. truncated = False
  2139. pending = [(te, 1)]
  2140. while pending:
  2141. entry, depth = pending.pop()
  2142. kept = [f for f in entry.stack if f.filename == "<model>"]
  2143. entry.stack = traceback.StackSummary.from_list(kept)
  2144. found = found or bool(kept)
  2145. # 3.11+ exception groups (a binding failure inside asyncio.TaskGroup)
  2146. # carry member stacks under `exceptions`, not the dunder links; a group
  2147. # member counts as a link so the cap bounds nesting through both edges.
  2148. members = getattr(entry, "exceptions", None) or ()
  2149. if depth >= _MAX_TRACEBACK_CHAIN:
  2150. if entry.__cause__ is not None or entry.__context__ is not None or members:
  2151. truncated = True
  2152. entry.__cause__ = None
  2153. entry.__context__ = None
  2154. if members:
  2155. entry.exceptions = None
  2156. continue
  2157. for linked in (entry.__cause__, entry.__context__):
  2158. if linked is not None:
  2159. pending.append((linked, depth + 1))
  2160. for member in members:
  2161. pending.append((member, depth + 1))
  2162. def emit():
  2163. if found:
  2164. yield from te.format()
  2165. else:
  2166. yield from traceback.format_exception_only(type(exc), exc)
  2167. if truncated:
  2168. yield f"[dsh-code-runtime-python] exception chain truncated at {_MAX_TRACEBACK_CHAIN} links\n"
  2169. return _join_bounded(emit(), max_bytes)
  2170. def _make_failure_reporter() -> Any:
  2171. """Build the failure-diagnostic renderer over closure-held primitives.
  2172. The returned callable renders a model failure diagnostic that cannot itself
  2173. raise. The caller sends the ``done`` frame AFTER its ``except BaseException``
  2174. block, so anything thrown while rendering the diagnostic skips the send
  2175. entirely: the host then blocks on fd 3 until ``maxWallMs`` and reports a
  2176. timeout instead of the exception that actually happened. Rendering runs
  2177. model code by design (``format()`` reaches ``__str__``, ``__repr__`` and
  2178. ``__notes__``) and allocates under ``RLIMIT_AS``, so it must be treated as
  2179. able to throw.
  2180. The fallback names the exception CLASS and a fixed literal — no ``str(exc)``
  2181. and no ``format_exception_only``, both of which reach the model's
  2182. ``__str__``. A ``__name__`` that is not exactly ``str`` (a metaclass
  2183. property can return anything, or raise) is discarded rather than
  2184. formatted, so no override runs on this path either.
  2185. The factory exists for the same reason :func:`_make_cpu_enforcer` does: this
  2186. bootstrap IS ``__main__``, so ``import __main__; __main__._X = ...`` rebinds
  2187. any module global a call-time lookup would read. On this path a rebind is
  2188. worst — the handler's own reporter, and everything the reporter reaches,
  2189. would run model code outside any guard, and a throw there costs the ``done``
  2190. frame. The traceback formatter, the byte cap and the fallback literal
  2191. therefore become closure cells captured at import time, before model code
  2192. runs, and :func:`_run` binds the returned callable into a local of its own
  2193. frame. A frame local is not a module attribute, so no assignment to
  2194. ``__main__`` changes which callable runs or what it calls. This defeats the
  2195. one-line rebind, not a determined ``sys._getframe`` walk; the unforgeable
  2196. bound is the host wall clock.
  2197. """
  2198. cap_message = _cap_message
  2199. model_traceback = _model_traceback
  2200. unrenderable = _UNRENDERABLE_DIAGNOSTIC
  2201. # The exception class the guards below catch is bound into a closure cell
  2202. # here, at import time, before model code runs. `safe_model_traceback` runs
  2203. # AFTER the program (which is `__main__`) may have rebound the module global
  2204. # `BaseException`, so `except BaseException` would resolve the rebound class
  2205. # and a render-time throw could escape — losing the done frame. A closure cell
  2206. # is not reachable by `__main__._X = ...`, so the catch is immune.
  2207. _BaseException = BaseException
  2208. def safe_model_traceback(exc: BaseException, max_bytes: int) -> str:
  2209. try:
  2210. return cap_message(model_traceback(exc, max_bytes), max_bytes)
  2211. except _BaseException: # noqa: BLE001 -- a throw here would cost the done frame
  2212. pass
  2213. try:
  2214. raw_name = type(exc).__name__
  2215. # Slice BEFORE interpolating. A metaclass `__name__` property can
  2216. # return an arbitrarily long string, and both the f-string and
  2217. # `cap_message`'s encode would copy it whole — under a tight
  2218. # RLIMIT_AS either allocation can raise MemoryError, and this is the
  2219. # LAST fallback, so a throw here costs the `done` frame outright and
  2220. # the run misreports as an exit or a timeout. The slice is a
  2221. # code-unit prefix, which bounds the bytes at 4x, and the following
  2222. # `cap_message` still applies the exact byte cap.
  2223. name = raw_name[:_MAX_FALLBACK_NAME_CHARS] if type(raw_name) is str else "<unknown>"
  2224. except _BaseException: # noqa: BLE001 -- a raising __name__ must not cost the done frame
  2225. name = "<unknown>"
  2226. # Wrapped for the same reason: `cap_message` encodes, and its allocation
  2227. # is the only step left that can still fail. The fixed literal needs no
  2228. # budget, so it can always be delivered.
  2229. try:
  2230. return cap_message(f"{name}: {unrenderable}", max_bytes)
  2231. except _BaseException: # noqa: BLE001 -- the done frame outranks the diagnostic's detail
  2232. return unrenderable
  2233. return safe_model_traceback
  2234. _SAFE_MODEL_TRACEBACK = _make_failure_reporter()
  2235. def _join_bounded(lines, max_bytes: int) -> str:
  2236. """Join formatter output, stopping once the budget is comfortably passed.
  2237. ``format()`` yields lines lazily; consuming it whole for an exception
  2238. carrying a huge message would materialize the full text only for
  2239. ``_cap_message`` to throw it away — enough over-shoot to exhaust
  2240. ``RLIMIT_AS``. Stop after the accumulated CHARACTER count passes the byte
  2241. budget (chars lower-bound UTF-8 bytes); the caller's ``_cap_message``
  2242. does the exact byte-level cut.
  2243. """
  2244. chunks: list[str] = []
  2245. total = 0
  2246. for line in lines:
  2247. # A single yielded line can itself dwarf the budget (the exception
  2248. # message rides in one line): keep only the prefix it can ever need.
  2249. if len(line) > max_bytes + 1:
  2250. line = line[: max_bytes + 1]
  2251. chunks.append(line)
  2252. total += len(line)
  2253. if total > max_bytes:
  2254. break
  2255. return "".join(chunks)
  2256. def _done_with_value(
  2257. value: Any,
  2258. max_value_bytes: int,
  2259. # Bound as DEFAULT ARGUMENTS so they are captured at import time, before
  2260. # model code runs: `_done_with_value` runs AFTER the program (which is
  2261. # `__main__`) may have rebound `__main__._check_done_value` or
  2262. # `__main__._encode_json_plain`, and a module-global lookup at call time
  2263. # would let a one-line rebind rewrite a legitimate success into an
  2264. # `exception`. Defaults are evaluated at def time, so they are the originals.
  2265. _check_done_value: Any = _check_done_value,
  2266. _encode_json_plain: Any = _encode_json_plain,
  2267. _cap_message: Any = _cap_message,
  2268. ) -> dict[str, Any] | str:
  2269. """Build the terminal done frame under the seam's lossless-JSON contract.
  2270. A completion value returned by the program (``None`` when it returns
  2271. nothing) that is not lossless JSON fails the run as ``invalid-output``; a
  2272. serialized value beyond ``max_value_bytes`` fails as ``output-limit``.
  2273. Substituting a ``repr`` or truncated string would be a silent lie about
  2274. what the program computed, so both paths refuse instead (mirroring the
  2275. worker backend's contract). ``None`` crosses as an exact JSON ``null``.
  2276. The SUCCESS path returns the whole ``"done"`` frame as an ALREADY-ENCODED
  2277. JSON string: the admitted value is serialized here, at its validation
  2278. point, rather than handed to ``send_sync`` to re-walk later. The program
  2279. can keep mutating the returned list/dict from a daemon thread or signal
  2280. handler after it returns, so a second traversal held at a later point
  2281. would be a TOCTOU — a mutation into a non-JSON type would let that later
  2282. encode throw outside the settlement handler and downgrade the run
  2283. host-side to ``worker-exit``. Serializing once, inside the try that wraps
  2284. this call, closes the window: if a concurrent mutation makes the encode
  2285. throw, the exception handler classifies it as ``exception``, and once the
  2286. string is produced the frame is sent verbatim with no further touching of
  2287. the live value. Returns a ``dict`` only for a rejection (an error frame
  2288. carries no live model value and is safe to send via ``send_sync``).
  2289. """
  2290. # One bounded walk folds the losslessness check and the byte meter (mirrors
  2291. # the host's checkDoneValue): the former split ran the full losslessness
  2292. # walk first, materializing one tuple per element for a wide completion
  2293. # before the size cap could reject it — an RLIMIT_AS death on a value the
  2294. # meter would have refused. The value's escaped JSON is then produced in the
  2295. # SAME call, so the admitted value is serialized exactly once (see above);
  2296. # its size the walk proved within budget. Iterative like the encoder, so a
  2297. # valid completion deeper than the recursion limit still checks.
  2298. rejection = _check_done_value(value, max_value_bytes)
  2299. if rejection is not None:
  2300. kind, message = rejection
  2301. # The rejection diagnostic is capped like an exception message: a
  2302. # reason embedding a hostile class name (a huge `type(value).__name__`)
  2303. # could otherwise make the done frame exceed the host's frame parse cap
  2304. # and be silently dropped — an invalid-output run misreported as a
  2305. # worker-exit.
  2306. return {"type": "done", "error": {"kind": kind, "message": _cap_message(message, max_value_bytes)}}
  2307. # Pre-encode the value at the validation point (not in `_run`'s later send,
  2308. # which is outside the try): see the TOCTOU note in the docstring. The value
  2309. # is JSON-plain by construction, so `_encode_json_plain` is the encoder.
  2310. return '{"type": "done", "value": ' + _encode_json_plain(value) + "}"
  2311. def main() -> None:
  2312. channel = ProtocolChannel(PROTOCOL_FD)
  2313. asyncio.run(_run(channel))
  2314. if __name__ == "__main__":
  2315. main()