index.ts 141 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454
  1. /**
  2. * CPython subprocess PTC runtime: a fresh `python3` process runs each model program under an
  3. * asyncio event loop with top-level ``await``. Binding calls travel on fd 3 as JSON-lines,
  4. * leaving stdout/stderr free for the program's own output. This is containment, not a security
  5. * boundary: model code has bash-equivalent trust, contained by a tempdir-only environment,
  6. * RLIMIT_CPU + RLIMIT_AS, wall-clock timeout, and SIGTERM→grace→SIGKILL on the process group.
  7. *
  8. * The package also owns the versionless fd-3 wire protocol itself; its host-side codec and
  9. * hostile-frame validators are re-exported so every consumer of the wire shares one vocabulary.
  10. * @module @deepseek-ai/dsh-experimental-ptc-runtime-python
  11. */
  12. import { execFileSync, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
  13. import { accessSync, copyFileSync, constants as fsConstants, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
  14. import { tmpdir } from 'node:os'
  15. import { delimiter, dirname, isAbsolute, join, resolve } from 'node:path'
  16. import { fileURLToPath } from 'node:url'
  17. import { getHeapStatistics } from 'node:v8'
  18. import type { Duplex } from 'node:stream'
  19. import { Context } from '@deepseek-ai/cordis'
  20. import z from '@deepseek-ai/schemastery'
  21. import { PtcRuntime, DUNDER_MEMBER, PORTABLE_RESERVED_WORDS, RESERVED_BINDING_GLOBALS, RESERVED_ERROR_MEMBERS } from '@deepseek-ai/dsh-ptc-runtime'
  22. import type { PtcBindingErrorClass, PtcBindingFunction, PtcJsonValue, PtcRunFailure, PtcRunRequest, PtcRunResult, PtcRunSpec } from '@deepseek-ai/dsh-ptc-runtime'
  23. import { snapshotJsonValue } from '@deepseek-ai/dsh-util-values'
  24. import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
  25. import type { BootMessage, ChildToHost, ReplyMessage } from './protocol.ts'
  26. import { checkDoneValue, encodeJsonPlain, hasUnsafeIntegerToken, logTruncationMarker, validateChildFrame } from './protocol.ts'
  27. // Re-export the fd-3 wire vocabulary so the runtime and its tests share one
  28. // import surface; the protocol layer owns the definitions.
  29. export type { BootMessage, ChildToHost, ReplyMessage } from './protocol.ts'
  30. export {
  31. checkDoneValue,
  32. encodeJsonPlain,
  33. hasNonLosslessNumber,
  34. hasUnsafeIntegerToken,
  35. logTruncationMarker,
  36. validateChildFrame,
  37. } from './protocol.ts'
  38. /** Plugin config: every cap, changeable from `cordis.yml` (no hardcoded tunables). */
  39. export interface Config {
  40. /**
  41. * RLIMIT_CPU in whole seconds (a positive integer — `setrlimit` in the child
  42. * rejects a float). The child sets the soft limit to `cpuSeconds` and the
  43. * hard limit to `cpuSeconds + 1`: the kernel delivers SIGXCPU at the soft
  44. * limit, which the host classifies as a `timeout`; the +1s hard limit is a
  45. * SIGKILL backstop for a program that traps SIGXCPU. Granularity is whole seconds.
  46. */
  47. cpuSeconds?: number
  48. /** Wall-clock ceiling in milliseconds; backstops CPU time for programs awaiting a promise nobody resolves. */
  49. maxWallMs?: number
  50. /**
  51. * RLIMIT_AS in mebibytes; caps address space so a runaway allocation fails
  52. * cleanly. Not applied on Darwin, where the dyld shared cache mapped into
  53. * every process at exec exceeds any practical cap and the kernel rejects
  54. * the call; `cpuSeconds` and `maxWallMs` still bound the run there. Bounds
  55. * `maxLogBytes`/`maxValueBytes` at load on EVERY platform (this static check
  56. * runs on Darwin too, where only the runtime `setrlimit` is skipped): each
  57. * budget times a worst-case Unicode expansion must fit this byte count minus a
  58. * fixed interpreter baseline, so a near-budget output cannot breach the address
  59. * space during the child's build-and-encode.
  60. */
  61. addressSpaceMb?: number
  62. /**
  63. * Shared byte budget for captured log text (host-side ledger). Bounded at load
  64. * against `addressSpaceMb`: the child builds and encodes a near-budget entry
  65. * under RLIMIT_AS with several copies live at once, so this cap times the
  66. * worst-case Unicode expansion must fit the address space left after the
  67. * interpreter baseline (see `addressSpaceMb`) — a load-time rejection, not a
  68. * runtime clamp. Also bounded at load by the host's configured heap like
  69. * `maxValueBytes` (see its JSDoc): the effective frame cap minus the frame
  70. * envelope.
  71. */
  72. maxLogBytes?: number
  73. /**
  74. * Byte cap for the completion value. Bounded at load against `addressSpaceMb`
  75. * the same way `maxLogBytes` is: the child builds and encodes a near-budget
  76. * value under RLIMIT_AS with several copies live at once, so this cap times the
  77. * worst-case Unicode expansion must fit the address space left after the
  78. * interpreter baseline. Both budgets are ALSO bounded at load by the host's
  79. * configured heap: the effective frame cap (the protocol cap, or a lower
  80. * heap-derived ceiling when the host heap cannot safely parse a near-cap
  81. * frame — see `hostFrameParseCeiling`) minus the frame envelope, so a budget
  82. * whose honest frame could OOM the host's own JSON.parse is rejected up
  83. * front.
  84. */
  85. maxValueBytes?: number
  86. /** SIGTERM→SIGKILL grace period on kill, matching bash-local's default. */
  87. graceMs?: number
  88. /**
  89. * Absolute path, relative path, or basename of a CPython 3.10+ interpreter.
  90. * Resolved and validated once at plugin load under a five-second force-kill
  91. * deadline; a basename searches `PATH`.
  92. */
  93. pythonBin?: string
  94. }
  95. /** {@link Config} with all defaults filled. */
  96. type ResolvedConfig = Required<Config>
  97. /**
  98. * The seam's language-portable identifier subset (see
  99. * `PtcBindingNamespace.global`) — identical to Python's identifier grammar,
  100. * so the shared contract needs no per-backend mapping here.
  101. */
  102. const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
  103. /**
  104. * The seam's cross-language reserved-word union: the portable-identifier
  105. * contract promises a namespace list valid here is valid on every backend, so
  106. * a JS keyword like `typeof` is refused even though it is a legal Python name.
  107. */
  108. const RESERVED_NAMES = PORTABLE_RESERVED_WORDS
  109. /**
  110. * The seam's shared backend-owned globals (`console` is the Node provider's slot;
  111. * `__dsh_main__`/`__builtins__`/`__name__` are this bootstrap's wrapper and
  112. * seeded module globals). Shared so a namespace list valid on one backend is
  113. * valid on all — colliding with an owned slot would be silently overwritten
  114. * (or overwrite builtins), so the seam rejects them up front.
  115. */
  116. const RUNTIME_OWNED_GLOBALS = RESERVED_BINDING_GLOBALS
  117. /**
  118. * The seam's shared error-member exclusions (`RESERVED_ERROR_MEMBERS` +
  119. * dunder-form names) — enforced identically here and in the Node backend so
  120. * an errorClass valid on one backend is valid on all. Several dunders are
  121. * constrained CPython descriptors whose `setattr` raises while constructing
  122. * the very rejection it was meant to carry; the exact set is an interpreter
  123. * version detail, hence the dunder-wide rule at the seam.
  124. */
  125. const EXCEPTION_RESERVED_MEMBERS = RESERVED_ERROR_MEMBERS
  126. const DUNDER = DUNDER_MEMBER
  127. /**
  128. * The `py/` scripts the interpreter must be able to open: the entry script plus
  129. * every module it imports from its own directory. Kept beside the built JS so a
  130. * consumer package with `files: ['lib', 'py']` ships both.
  131. */
  132. const PY_SCRIPTS = ['bootstrap.py', 'protocol.py']
  133. /**
  134. * Copy the `py/` scripts to a real filesystem directory and return the entry
  135. * script's path there.
  136. *
  137. * The interpreter is an EXTERNAL process, so it can only open paths the OS
  138. * resolves. Inside the single-file Python-SDK executable, `import.meta.url`
  139. * resolves into pkg's virtual filesystem, which Node reads through its patched
  140. * `fs` but `python3` cannot see at all — the spawn fails with ENOENT on a path
  141. * that exists as far as the host is concerned. `bootstrap.py` additionally
  142. * inserts its own directory on `sys.path` to import the sibling `protocol.py`,
  143. * so both files must land in the SAME real directory.
  144. *
  145. * The copy is unconditional rather than gated on a bundled-runtime probe: the
  146. * read goes through Node's `fs` either way, and one code path means the
  147. * packaged deployment runs what the tests exercise. Placement is under
  148. * `os.tmpdir()` with `0o700` keeps the scripts off other users' reach, but NOT
  149. * the model's: the child runs as the same UID as the host, so a program can
  150. * rewrite the very files it was started from. Hence one copy per RUN, discarded
  151. * at settlement — a rewrite then damages only the run that performed it, which
  152. * is what fresh-subprocess-per-run already promises. Sharing one copy across
  153. * runs made an overwritten `bootstrap.py` break the next run.
  154. *
  155. * Deliberately SYNCHRONOUS. An `await` here would open an async boundary in
  156. * `execute` before the run is registered in `live` and before the abort
  157. * listener is installed, so a disposal or an abort landing in that window would
  158. * be missed: `teardown` would see no runs and return while the continuation
  159. * went on to spawn a subprocess, and an `addEventListener('abort')` installed
  160. * afterwards does not replay an event that already fired. Three small
  161. * filesystem operations per run are not worth that class of race, and `execute`
  162. * already runs synchronously up to `spawn`.
  163. *
  164. * A failed copy removes the directory here, so a partial attempt never outlives
  165. * the call that made it; a successful one is the caller's to remove, which it
  166. * derives from the returned path.
  167. *
  168. * @returns the absolute path of the materialized entry script.
  169. */
  170. function materializePyScripts(): string {
  171. const dir = mkdtempSync(join(tmpdir(), 'dsh-ptc-runtime-python-'))
  172. const source = fileURLToPath(new URL('../py/', import.meta.url))
  173. try {
  174. for (const name of PY_SCRIPTS) copyFileSync(join(source, name), join(dir, name))
  175. } catch (error: unknown) {
  176. try {
  177. rmSync(dir, { recursive: true, force: true })
  178. } catch {
  179. // Swallows only a failure to remove the partial staging directory. The
  180. // caller reports the copy failure that got us here, which is the
  181. // diagnosable one; nothing else can act on a temp dir we cannot unlink.
  182. }
  183. throw error
  184. }
  185. return join(dir, 'bootstrap.py')
  186. }
  187. /**
  188. * A frame's RAW length is capped before JSON.parse: the 64 MiB fd-3 frame
  189. * parse cap bounds the bytes, not the decoded structure, and a compact wide
  190. * frame near that ceiling (e.g. a huge array of tiny elements) could decode to
  191. * far more host memory than the wire admitted — an OOM inside the receive
  192. * path. 64 MiB raw admits every legal config (the widest in-tree completion
  193. * and binding frames are ~12 MB) while bounding decode amplification to a
  194. * roughly constant factor of the wire bytes. The unframed-buffer counter is
  195. * checked against this same cap BEFORE a `Buffer.concat` join, so an oversized
  196. * frame is dropped at one copy of its wire bytes. A hostile-peer invariant,
  197. * not a deployment choice.
  198. */
  199. const FRAME_PARSE_CAP_BYTES = 64 * 1024 * 1024
  200. /**
  201. * Fragments the unframed fd-3 buffer may hold before they are coalesced into
  202. * one Buffer, bounding retained per-chunk overhead that the byte cap cannot
  203. * see: the cap meters payload bytes, while each chunk is a distinct Buffer
  204. * with its own object and backing store. A
  205. * program writing single bytes without a newline produced one chunk per write.
  206. * 1024 keeps the overhead a small constant factor of the payload while leaving
  207. * normal pipe-sized reads (which arrive in far fewer, much larger chunks)
  208. * untouched. A framing invariant, not a deployment choice.
  209. */
  210. const MAX_PENDING_CHUNKS = 1024
  211. /**
  212. * Replies the host retains before fd 3 accepts them. The drain loop writes one
  213. * reply per iteration and waits for `drain` when the pipe is full; a child
  214. * that never reads its replies (hostile or wedged) leaves the pipe full, so
  215. * every call frame it keeps sending adds a reply the drain cannot write, and
  216. * the backlog would grow without bound until the wall clock. 1024 keeps
  217. * legitimate concurrent gathers (measured queue depths reach 11) far below
  218. * the ceiling while bounding the hostile backlog; the run settles as a
  219. * worker-exit past it, like the frame cap settles an oversized frame. A
  220. * framing invariant, not a deployment choice.
  221. */
  222. const MAX_PENDING_REPLIES = 1024
  223. /**
  224. * Bytes a frame spends on its own JSON structure around a capped payload, used
  225. * to bound `maxLogBytes`/`maxValueBytes` against {@link FRAME_PARSE_CAP_BYTES}
  226. * (the receive path rejects raw frames past that cap, settling the run as a
  227. * worker-exit).
  228. * The widest carrier is `{"type":"log","text":"","truncated":true}` at 41
  229. * bytes; 64 rounds that up so adding a field to either frame does not silently
  230. * invalidate the bound. A protocol constant, not a deployment choice.
  231. */
  232. const FRAME_ENVELOPE_BYTES = 64
  233. /**
  234. * Smallest `maxLogBytes` the backend can honor. The truncation marker alone
  235. * (`logTruncationMarker`) must serialize within the budget, or a marker-only
  236. * truncated run returns more than the configured cap: the marker text is
  237. * `[dsh-ptc-runtime-python] log capture truncated at <N> bytes` — 50 fixed
  238. * characters (the bracketed prefix `[dsh-ptc-runtime-python] log capture
  239. * truncated at ` counts both square brackets) plus the digits of N plus 6 —
  240. * and its serialized form adds 4 (two quotes, two array brackets), so the
  241. * smallest N that admits its own marker is 62 (50 + 2 + 6 + 4 = 62); 64 is the
  242. * floor with two bytes of room. The marker itself remains envelope, not
  243. * payload, so a truncated run with admitted entries serializes to at most
  244. * `maxLogBytes + marker + envelope`.
  245. * `maxValueBytes` has no floor beyond the positive-integer requirement: a
  246. * completion can be as small as a single byte (`1`), and the done-frame
  247. * envelope is seam protocol cost, not the advertised completion budget.
  248. */
  249. const MIN_LOG_BYTES = 64
  250. /**
  251. * Extra time added to `graceMs` before the post-kill close-deadline force-settles
  252. * a run whose `close` never fires (a setsid-escaped orphan holds our inherited
  253. * stdio; see the `closeDeadline` arm in {@link PythonPtcRuntime.execute}). It
  254. * covers the OS reaping the killed child itself after SIGKILL — not a deployment
  255. * choice but a fixed safety margin, so it is a constant rather than a config knob.
  256. */
  257. const CLOSE_REAP_MARGIN_MS = 2_000
  258. /**
  259. * Worst-case peak child-process bytes a one-`maxLogBytes`/`maxValueBytes`-budget
  260. * output can transiently occupy while the child charges and frames it, expressed
  261. * as a multiple of the budget. The child's ledgers trigger on CHARACTER count
  262. * against a serialized-BYTE budget, and an astral character is one character but
  263. * four bytes of CPython `str` storage and four UTF-8 bytes — so a budget's worth
  264. * of astral characters is ~4x the budget in each string that holds it. The
  265. * heaviest path holds THREE such copies at once: a single
  266. * `sys.stdout.write(line + "\n")` keeps the caller's `text` argument (alive for
  267. * the whole `write` call, ~4x), the line slice `text[pos:newline]` handed to
  268. * `LogBuffer.push` (~4x), and the `text.encode("utf-8")` copy `_push_locked`
  269. * takes to charge and ship it (~4x). The settlement `flush_line` path holds only
  270. * two (its `"".join(...)` and that encode copy — it drops the pending chunks
  271. * before pushing), so the newline path is the binding worst case. Twelve covers
  272. * those three simultaneous ~4x copies. The interpreter baseline is NOT in this
  273. * multiple — it is reserved separately as {@link INTERPRETER_BASELINE_BYTES} —
  274. * because it is a fixed cost, not one that scales with the budget. Used to bound
  275. * `maxLogBytes`/`maxValueBytes` against `addressSpaceMb` at load, with a `>=` so
  276. * a budget whose worst-case peak exactly equals the room left after the baseline
  277. * is rejected (that peak plus the baseline is the whole address space, the
  278. * RLIMIT_AS edge), so a legitimate near-budget output truncates (log) or fails
  279. * as `output-limit` (value) rather than breaching `RLIMIT_AS` as `worker-exit`.
  280. * A fixed safety invariant tying the budgets to the address space, not a knob.
  281. */
  282. const OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE = 12
  283. /**
  284. * Fixed address-space headroom reserved for the CPython interpreter itself
  285. * (loaded modules, the asyncio loop, import machinery) before the output-budget
  286. * multiple claims the rest. The budget check subtracts this from `addressSpaceMb`
  287. * so a budget sized right at `addressSpaceMb / MULTIPLE` — which the multiple
  288. * alone would admit — cannot leave the peak output allocation plus the
  289. * interpreter over the limit. Sized against ADDRESS SPACE, which is what
  290. * `RLIMIT_AS` bounds, not resident set: the bootstrap's own measurement is
  291. * 30.23 MiB of mappings for a `python3 -I` child (see `_make_cpu_enforcer`,
  292. * which also records the 64 MiB glibc per-thread arena reservation that pushes
  293. * it to 102.37 MiB when threads are used). 64 MiB is roughly twice the measured
  294. * baseline, leaving room for allocator arenas and import jitter. The value is a
  295. * fixed safety margin, not a deployment knob.
  296. */
  297. const INTERPRETER_BASELINE_BYTES = 64 * 1024 * 1024
  298. /**
  299. * Worst-case peak host-heap bytes the PARSE of one inbound fd-3 frame can
  300. * transiently occupy, expressed as a multiple of the frame's raw bytes.
  301. * `JSON.parse` of a wide container materializes the object's property storage
  302. * and key strings on top of the raw text; the WORST shape is a dict of many
  303. * SHORT UNIQUE keys, which forces V8's dictionary-mode property storage
  304. * (~32-64 bytes per entry) plus one interned string per key (header + data)
  305. * plus string-table growth: measured 6.4x for a 3,000,000-key frame (~31 MB
  306. * raw) on a 1 GiB heap, trending up with key count (a flat unique-key array
  307. * is ~4x, a repeated-key dict ~3x). On a constrained heap the parse also
  308. * retains the raw frame string while the object builds, so the safety factor
  309. * is 16x — ~2.5x over the measured worst shape, ~1.6x over the claimed
  310. * GC-headroom bound. Used with the host's configured heap limit to derive the
  311. * largest frame whose parse cannot OOM the host process. This bounds the
  312. * HOST's parse; {@link OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE} bounds
  313. * the CHILD's build and encode under RLIMIT_AS, a different resource. A fixed
  314. * safety invariant, not a knob.
  315. */
  316. const HOST_PARSE_WORST_CASE_MULTIPLE = 16
  317. /**
  318. * Fixed host-heap headroom reserved for the application itself (the dsh
  319. * fiber, plugins, and this runtime's own state) before the frame-parse
  320. * multiple claims the rest: the effective frame cap is derived from
  321. * `heap_size_limit - HOST_PARSE_BASELINE_BYTES`, so a constrained host's
  322. * parse ceiling never spends the application's working set. A fixed safety
  323. * margin, not a knob.
  324. */
  325. const HOST_PARSE_BASELINE_BYTES = 64 * 1024 * 1024
  326. /**
  327. * The largest inbound fd-3 frame the HOST can parse without risking a
  328. * process-level OOM on its current heap: the configured heap limit (honoring
  329. * `--max-old-space-size`) minus the application baseline, divided by the
  330. * worst-case parse multiple, floored to the protocol frame cap. The
  331. * raw-byte cap alone does not protect the heap — `JSON.parse` of a
  332. * ≤64 MiB wide-object frame materializes several times that in property
  333. * storage — so the effective cap is the smaller of the two. A default Node
  334. * heap (~4 GiB) never binds; a constrained host (e.g.
  335. * `--max-old-space-size=256` reports a ~300 MiB limit) lowers it to ~14 MiB,
  336. * and the load gate rejects budgets that cannot cross it.
  337. * @param heapLimit - the host's configured heap limit; the live
  338. * `heap_size_limit` when omitted. A parameter so the derivation is unit
  339. * testable against simulated heap sizes.
  340. * @returns the effective frame parse cap in bytes.
  341. */
  342. export function hostFrameParseCeiling(heapLimit: number = getHeapStatistics().heap_size_limit): number {
  343. return Math.min(FRAME_PARSE_CAP_BYTES, Math.floor((heapLimit - HOST_PARSE_BASELINE_BYTES) / HOST_PARSE_WORST_CASE_MULTIPLE))
  344. }
  345. /**
  346. * Interval between process-group liveness probes while settlement waits for an
  347. * escalated SIGKILL to empty the group (see the `killing` branch in
  348. * {@link PythonPtcRuntime.execute}'s settle). A poll rather than an event
  349. * because the group members are the model's own descendants, which the host does
  350. * not `wait()` for and gets no exit signal from; the probe is a signal-0
  351. * `process.kill(-pid, 0)`, so the interval only bounds how promptly a now-empty
  352. * group is noticed, capped by `graceMs + CLOSE_REAP_MARGIN_MS`.
  353. */
  354. const GROUP_REAP_POLL_MS = 50
  355. /**
  356. * Extract a human message from an unknown thrown value.
  357. *
  358. * `String(error)` runs the value's own conversion, and a host binding may reject
  359. * with an object whose `Symbol.toPrimitive` or `toString` throws. One call site
  360. * is a detached async reply callback, where that throw escapes as an unhandled
  361. * rejection: the reply frame is never written, the program stays blocked on
  362. * `await`, and the run degrades to a `maxWallMs` timeout (a Node host without an
  363. * `unhandledRejection` listener exits outright). The conversion is therefore
  364. * wrapped, with a fixed literal as the fallback — the value already proved it
  365. * cannot be rendered, so nothing derived from it is safe to try.
  366. *
  367. * `Error.message` is typed `string` but is a plain writable property, so a
  368. * rejecting binding can hand back an `Error` carrying any value there. The
  369. * `Error` arm therefore goes through the same conversion rather than returning
  370. * `message` verbatim: the returned string crosses the wire under
  371. * `encodeJsonPlain`'s JSON-plain precondition, where a cyclic object grows the
  372. * encoder stack until the host exhausts memory and any other unsupported value
  373. * prevents the reply frame outright.
  374. *
  375. * The same conversion renders abort reasons, which reach an `AbortSignal`
  376. * listener: Node reports a throw from such a listener as an uncaught exception,
  377. * so an unwrapped conversion there can terminate the host with the run left
  378. * unsettled.
  379. *
  380. * @param error The thrown value, of unknown shape.
  381. * @returns The value's message or string form; a fixed placeholder when its own
  382. * conversion throws.
  383. */
  384. function messageOf(error: unknown): string {
  385. try {
  386. return String(error instanceof Error ? error.message : error)
  387. } catch {
  388. // Swallows only a throw from the value's own `message` getter or string
  389. // conversion. Nothing else runs inside the try, and the placeholder is a
  390. // literal, so this cannot throw again.
  391. return '<unrenderable rejection value>'
  392. }
  393. }
  394. /**
  395. * A process's start time, as the identity half of (pid, started).
  396. *
  397. * A pid is reusable the moment the kernel reaps it, so signalling one that a
  398. * later process inherited would terminate an unrelated process group. Start
  399. * time is what distinguishes the original from its replacement: `kill(pid, 0)`
  400. * answers "does this number exist", which is true for both.
  401. *
  402. * Linux reads field 22 of `/proc/<pid>/stat` (starttime in clock ticks); the
  403. * field is positional after the comm field's closing parenthesis, which is
  404. * parsed from the LAST such character because a process name may contain one.
  405. * Darwin has no `/proc`, so the caller gets `undefined` there and `killGroup`
  406. * signals the pgid without the identity re-check rather than paying a `ps`
  407. * fork on a teardown path. Any read failure is `undefined` for the same
  408. * reason: this
  409. * hardens a narrow race and must never be the thing that breaks teardown.
  410. * @param pid - the process to read.
  411. * @returns its start time, or undefined when unavailable.
  412. */
  413. export function readProcessStart(pid: number): string | undefined {
  414. /* v8 ignore next -- one arm per platform: the Linux coverage lane always takes the read path, and Darwin always this one. */
  415. if (process.platform !== 'linux') return undefined
  416. try {
  417. const stat = readFileSync(`/proc/${String(pid)}/stat`, 'utf8')
  418. const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ')
  419. // Field 22 overall; the slice above dropped pid and comm, so it is index 19.
  420. return fields[19]
  421. } catch {
  422. return undefined
  423. }
  424. }
  425. /**
  426. * Resolve `pythonBin` to one executable absolute path at plugin load. A basename
  427. * (the default `python3`) searches the current process `PATH`; the child receives
  428. * no `PATH`, so Node's own lookup would otherwise fall back to the platform
  429. * default (`/usr/bin:/bin`) and miss interpreters
  430. * that live only on the caller's `PATH` (Nix, pyenv, Homebrew, conda). An
  431. * absolute path is verified in place, and an explicitly relative path is first
  432. * resolved against the load-time working directory. When no candidate is an
  433. * executable regular file, `undefined` is returned and the load check rejects
  434. * the configuration: falling back to the bare name would let spawn's scrubbed env
  435. * execvp silently start a system interpreter from the platform default PATH
  436. * that the caller never asked for.
  437. * @param bin - the configured interpreter (absolute path, relative path, or bare command).
  438. * @returns an absolute path when resolvable, else `undefined`.
  439. */
  440. export function resolvePythonBin(bin: string): string | undefined {
  441. const executableFile = (candidate: string): string | undefined => {
  442. try {
  443. accessSync(candidate, fsConstants.X_OK)
  444. return statSync(candidate).isFile() ? candidate : undefined
  445. } catch {
  446. // Missing, inaccessible, and non-stat-able candidates are ordinary
  447. // lookup misses; the constructor reports the final load error.
  448. return undefined
  449. }
  450. }
  451. if (isAbsolute(bin)) return executableFile(bin)
  452. if (bin.includes('/')) return executableFile(resolve(bin))
  453. const path = process.env.PATH
  454. /* v8 ignore next -- PATH is set in every environment the runtime boots in; the guard is defensive. */
  455. if (path === undefined) return undefined
  456. for (const dir of path.split(delimiter)) {
  457. // An empty PATH segment (a `::`, implicitly CWD on POSIX) and a RELATIVE
  458. // segment (`bin` or `.`) are skipped: a basename must never resolve against
  459. // the working directory, and the returned candidate must be an absolute
  460. // path — spawn() resolves a relative pythonBin against the host CWD, which
  461. // is outside the seam contract.
  462. if (dir === '' || !isAbsolute(dir)) continue
  463. const executable = executableFile(join(dir, bin))
  464. if (executable !== undefined) return executable
  465. }
  466. return undefined
  467. }
  468. /** Lowest CPython version supported by the bootstrap and its traceback behavior. */
  469. const MIN_CPYTHON = { major: 3, minor: 10 } as const
  470. /** Fixed load-time probe bound; a configured executable must not hang plugin activation. */
  471. const PYTHON_PROBE_TIMEOUT_MS = 5_000
  472. /** The only host environment fact exposed to the child. */
  473. function pythonEnvironment(): NodeJS.ProcessEnv {
  474. return { TMPDIR: tmpdir() }
  475. }
  476. /** Fail load unless `bin` is a responsive CPython 3.10+ interpreter. */
  477. function validatePythonBin(bin: string): void {
  478. let output: string
  479. try {
  480. output = execFileSync(bin, [
  481. '-I',
  482. '-c',
  483. 'import sys; print(sys.implementation.name, sys.version_info.major, sys.version_info.minor, sys.version_info.micro)',
  484. ], {
  485. encoding: 'utf8',
  486. env: pythonEnvironment(),
  487. timeout: PYTHON_PROBE_TIMEOUT_MS,
  488. // The configured executable is outside our control. Force-kill it at the
  489. // deadline so a wrapper that ignores SIGTERM cannot block plugin load.
  490. killSignal: 'SIGKILL',
  491. maxBuffer: 1_024,
  492. }).trim()
  493. } catch (error: unknown) {
  494. throw new Error(`dsh-ptc-runtime-python: config.pythonBin ${JSON.stringify(bin)} failed the CPython version probe: ${messageOf(error)}`)
  495. }
  496. const match = /^(\S+) (\d+) (\d+) (\d+)$/.exec(output)
  497. if (match === null) {
  498. throw new Error(`dsh-ptc-runtime-python: config.pythonBin ${JSON.stringify(bin)} did not report a CPython version`)
  499. }
  500. const [, implementation, majorText, minorText, patchText] = match
  501. const major = Number(majorText)
  502. const minor = Number(minorText)
  503. if (implementation !== 'cpython') {
  504. throw new Error(`dsh-ptc-runtime-python: config.pythonBin ${JSON.stringify(bin)} must be CPython, got ${implementation}`)
  505. }
  506. if (major < MIN_CPYTHON.major || (major === MIN_CPYTHON.major && minor < MIN_CPYTHON.minor)) {
  507. throw new Error(`dsh-ptc-runtime-python: config.pythonBin ${JSON.stringify(bin)} must be CPython ${MIN_CPYTHON.major}.${MIN_CPYTHON.minor} or newer, got ${implementation} ${majorText}.${minorText}.${patchText}`)
  508. }
  509. }
  510. /** The marker appended when a diagnostic message is byte-capped host-side. */
  511. const TRUNCATION_MARKER = '… [truncated]'
  512. /**
  513. * The marker's own UTF-8 byte length, reserved out of the budget so a capped
  514. * message stays WITHIN `maxValueBytes` rather than exceeding it by the marker.
  515. * The ellipsis is 3 bytes, so this is 15, not the string's 13 code units.
  516. */
  517. const TRUNCATION_MARKER_BYTES = Buffer.byteLength(TRUNCATION_MARKER, 'utf8')
  518. // Fatal UTF-8 decoder for fd-3 frames: `toString('utf8')` replaces illegal
  519. // bytes with U+FFFD, which would silently corrupt a completion or binding
  520. // payload a forged frame smuggled in; a fatal decode throws instead and the
  521. // frame is dropped. Non-stream mode keeps it stateless across lines.
  522. const UTF8_FATAL = new TextDecoder('utf-8', { fatal: true })
  523. /**
  524. * Serialized JSON byte width of one character, given its code point and the
  525. * one-character string. Control characters below 0x20 escape to `\uXXXX` (6)
  526. * except the five with short forms `\b \t \n \f \r` (2); `"` and `\` escape to
  527. * 2; a LONE surrogate escapes to `\uXXXX` (6) under ES2019 well-formed
  528. * `JSON.stringify`; everything else rides at its raw UTF-8 width.
  529. * @param code - the character's code point.
  530. * @param character - the one-character (or one-code-point) string.
  531. * @returns the character's serialized JSON byte width.
  532. */
  533. function serializedCharCost(code: number, character: string): number {
  534. if (code < 0x20) return code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d ? 2 : 6
  535. if (code === 0x22 || code === 0x5c) return 2
  536. if (code >= 0xd800 && code <= 0xdfff) return 6
  537. return Buffer.byteLength(character, 'utf8')
  538. }
  539. /**
  540. * Serialized JSON-string cost of `text` (the two quotes plus each character's
  541. * escaped byte width), measured WITHOUT materializing the escaped copy, and
  542. * abandoned the instant it exceeds `maxBytes`. `JSON.stringify(text)` would
  543. * allocate the whole escaped form first — up to sixfold a control-char-dense
  544. * string — so a near-budget line under a large `maxLogBytes` could momentarily
  545. * allocate over a gigabyte just to measure it. This walks code point by code
  546. * point (a matched surrogate pair yields its combined code point ≥ 0x10000; a
  547. * lone surrogate yields a value in 0xD800–0xDFFF that {@link serializedCharCost}
  548. * charges the full six escaped bytes) and stops at the cap, allocating nothing.
  549. * @param text - the candidate string.
  550. * @param maxBytes - the largest serialized size the caller can admit.
  551. * @returns the exact serialized byte cost, or `undefined` once it exceeds `maxBytes`.
  552. */
  553. function jsonStringCostUpTo(text: string, maxBytes: number): number | undefined {
  554. if (maxBytes < 2) return undefined
  555. let bytes = 2 // the enclosing quotes
  556. for (const character of text) {
  557. bytes += serializedCharCost(character.codePointAt(0) as number, character)
  558. if (bytes > maxBytes) return undefined
  559. }
  560. return bytes
  561. }
  562. /**
  563. * Cross-chunk UTF-8 state for {@link accrueStrayCost}: `expected` continuation
  564. * bytes still needed to finish the in-progress sequence, its total `width`, and
  565. * `lowerFirst`/`upperFirst`, the valid range for the NEXT continuation byte
  566. * (only the first continuation of a 3- or 4-byte lead is range-restricted; once
  567. * consumed, later continuations accept the full 0x80–0xBF). All zero between
  568. * sequences. Carried on each {@link StrayBuffer} so a multibyte character split
  569. * across pipe `data` chunks is costed as one character.
  570. */
  571. interface Utf8CostState { expected: number; width: number; lowerFirst: number; upperFirst: number }
  572. /**
  573. * Accrue the serialized JSON cost of raw pipe bytes `buf`, decoding UTF-8 the way
  574. * `toString('utf8')` (WHATWG) would so a byte that renders as U+FFFD is charged
  575. * the three bytes that replacement character serializes to. A naive tally that
  576. * charged every byte 1 let a `b"\xff"` flood (every byte illegal → U+FFFD each)
  577. * grow the residual to a full budget's worth of raw bytes before flushing; near
  578. * a large `maxLogBytes` that retained ~256 MiB, then `flushStray`'s
  579. * `Buffer.concat` + `toString` expanded it to a ~1 GiB peak. Charging only the
  580. * structural width would leave the same gap for structurally-well-formed but
  581. * ILLEGAL sequences a flood produces just as cheaply — a CESU-8 surrogate
  582. * (`ED A0 80`) or an overlong (`E0 80 80`) decodes to THREE U+FFFD (cost 9), not
  583. * one width-3 character, so this validates each lead's first continuation range
  584. * (WHATWG: `E0`→A0-BF, `ED`→80-9F, `F0`→90-BF, `F4`→80-8F, others 80-BF) and
  585. * charges 3 per byte of any sequence that breaks. A control byte below 0x20
  586. * costs 6 (`\uXXXX`) or 2 (five short escapes); `"`/`\` cost 2; ASCII costs 1; a
  587. * fully valid multibyte sequence costs its byte width (2/3/4). `state` carries
  588. * the in-progress sequence across chunks; an unfinished tail at stream end is
  589. * decoded by the final `flushStray` and costed exactly there.
  590. * @param buf - raw bytes from a stdout/stderr pipe chunk.
  591. * @param state - the pipe's carried UTF-8 sequence state, mutated in place.
  592. * @returns the serialized cost accrued by the bytes that resolved in this call.
  593. */
  594. function accrueStrayCost(buf: Buffer, state: Utf8CostState): number {
  595. let cost = 0
  596. let index = 0
  597. while (index < buf.length) {
  598. const byte = buf[index] as number
  599. if (state.expected > 0) {
  600. // The valid range for THIS continuation: the lead-specific range applies
  601. // to the first continuation only, then reverts to the full 0x80–0xBF.
  602. const consumed = state.width - state.expected
  603. const lower = consumed === 1 ? state.lowerFirst : 0x80
  604. const upper = consumed === 1 ? state.upperFirst : 0xbf
  605. if (byte >= lower && byte <= upper) {
  606. state.expected -= 1
  607. if (state.expected === 0) {
  608. cost += state.width
  609. state.width = 0
  610. }
  611. index += 1
  612. continue
  613. }
  614. // The sequence broke. WHATWG's maximal-subpart rule folds the bytes
  615. // consumed so far into ONE U+FFFD (cost 3), then reprocesses this byte as
  616. // a fresh start (no index advance). Charging per consumed byte would
  617. // over-count, which is memory-safe but wrong; folding to one is exact.
  618. cost += 3
  619. state.expected = 0
  620. state.width = 0
  621. continue
  622. }
  623. if (byte < 0x20) {
  624. cost += byte === 0x08 || byte === 0x09 || byte === 0x0a || byte === 0x0c || byte === 0x0d ? 2 : 6
  625. } else if (byte === 0x22 || byte === 0x5c) {
  626. cost += 2
  627. } else if (byte < 0x80) {
  628. cost += 1
  629. } else if (byte >= 0xc2 && byte <= 0xdf) {
  630. state.expected = 1
  631. state.width = 2
  632. state.lowerFirst = 0x80
  633. state.upperFirst = 0xbf
  634. } else if (byte >= 0xe0 && byte <= 0xef) {
  635. state.expected = 2
  636. state.width = 3
  637. // Exclude the overlong (E0 80-9F) and CESU-8 surrogate (ED A0-BF) ranges.
  638. state.lowerFirst = byte === 0xe0 ? 0xa0 : 0x80
  639. state.upperFirst = byte === 0xed ? 0x9f : 0xbf
  640. } else if (byte >= 0xf0 && byte <= 0xf4) {
  641. state.expected = 3
  642. state.width = 4
  643. // Exclude the overlong (F0 80-8F) and out-of-range (F4 90-BF) leads.
  644. state.lowerFirst = byte === 0xf0 ? 0x90 : 0x80
  645. state.upperFirst = byte === 0xf4 ? 0x8f : 0xbf
  646. } else {
  647. // 0x80–0xc1 and 0xf5–0xff never begin a valid sequence: U+FFFD (3).
  648. cost += 3
  649. }
  650. index += 1
  651. }
  652. return cost
  653. }
  654. /**
  655. * Cap a done-frame `error.message` to `maxValueBytes` host-side: a forged done
  656. * frame can carry an arbitrarily long message, so truncate by RAW UTF-8 byte
  657. * length and append the shared marker on overflow. Completion VALUES are never
  658. * truncated — the seam forbids substitution, so an oversized value fails the run
  659. * as `output-limit` instead (see the done case in `execute`).
  660. *
  661. * This is the RECEIVE-side backstop, and it bills by raw bytes on purpose,
  662. * unlike the producing-side `_cap_message` in `py/bootstrap.py`, which bills by
  663. * SERIALIZED (JSON-escaped) cost. The split is deliberate: `_cap_message`'s
  664. * output has to cross fd 3 as a JSON string, so its escaped width is what the
  665. * frame ceiling bounds; this function's output goes straight into
  666. * `PtcRunResult.error.message` and never re-crosses a frame-bounded channel, so
  667. * the honest measure of what it retains is the raw length. An honest child has
  668. * already capped the diagnostic by serialized cost, and raw length ≤ serialized
  669. * cost, so a well-formed message passes through unchanged. A forged message with
  670. * control characters could serialize to roughly six times its raw length, but it
  671. * is not travelling any capped channel, so the raw-byte bound is the right one:
  672. * the value it protects is the model-visible size of `error.message`, not a wire
  673. * width.
  674. *
  675. * The marker's bytes are RESERVED from the budget, not added on top: the whole
  676. * returned string, marker included, is at most `maxValueBytes` bytes. Appending
  677. * the marker after retaining a full budget's worth of text would overrun the
  678. * very cap this function exists to enforce. The one exception is a configured
  679. * cap SMALLER than the marker itself, which leaves no room for message text at
  680. * all; the marker alone is returned there, so the bound is
  681. * `max(maxValueBytes, 15)`. Reporting the truncation is worth those 15 bytes,
  682. * and the default cap is 32 KiB.
  683. * @param message - the error message from an inbound (possibly forged) done frame.
  684. * @param maxValueBytes - the configured completion-value budget, reused here.
  685. * @returns the message unchanged, or its byte-capped form on overflow.
  686. */
  687. function capMessage(message: string, maxValueBytes: number): string {
  688. // Code-unit bounds BEFORE any encode, so a forged done frame carrying a
  689. // message anywhere below the 64 MiB fd-3 frame parse cap cannot force a
  690. // full-length UTF-8 copy under a 32 KiB cap. One UTF-16 code unit encodes to
  691. // at least one UTF-8 byte and at most three: three for a non-ASCII BMP
  692. // character, two apiece for the pair halves sharing an astral code point's
  693. // four bytes, and three for a LONE surrogate, which `Buffer.from` renders as
  694. // U+FFFD. So at most maxValueBytes/3 code units cannot overflow the cap and
  695. // need no encode at all...
  696. if (message.length * 3 <= maxValueBytes) return message
  697. // ...and nothing past the first maxValueBytes code units can fit inside it,
  698. // so only that prefix is ever encoded — at most 3 * maxValueBytes bytes.
  699. const keep = Math.min(message.length, maxValueBytes)
  700. const whole = keep === message.length
  701. const bytes = Buffer.from(whole ? message : message.slice(0, keep), 'utf8')
  702. // A message that fits is measured against the WHOLE cap: it gets no marker,
  703. // so reserving marker bytes here would truncate text that was within budget.
  704. if (whole && bytes.length <= maxValueBytes) return message
  705. // Past this point the message IS being truncated, so the marker WILL be
  706. // appended and its bytes come out of the cap instead of sitting on top of it.
  707. const budget = Math.max(0, maxValueBytes - TRUNCATION_MARKER_BYTES)
  708. // Trim back to the last complete UTF-8 sequence: a cut through a multibyte
  709. // character would decode as U+FFFD — corrupting the diagnostic AND
  710. // exceeding the byte cap, since the replacement character itself encodes
  711. // to three bytes. Continuation bytes are 0b10xxxxxx; at most three of them
  712. // precede a lead byte.
  713. //
  714. // This also covers a code-unit prefix ending on a HIGH SURROGATE whose low
  715. // half sits outside it, which `Buffer.from` encodes as U+FFFD: that orphan
  716. // occupies the last three bytes of `bytes`, and `bytes` is at least
  717. // `maxValueBytes + 2` long here (one byte per retained unit, three for the
  718. // orphan), so it starts past `budget` and is always cut. Reserving the
  719. // marker is what makes that hold; cutting at `maxValueBytes` itself did not,
  720. // and needed an explicit surrogate check.
  721. let end = Math.min(budget, bytes.length)
  722. while (end > 0 && ((bytes[end] as number) & 0b1100_0000) === 0b1000_0000) end--
  723. return `${bytes.subarray(0, end).toString('utf8')}${TRUNCATION_MARKER}`
  724. }
  725. /**
  726. * Copy an fd-3 line residual into a fresh, right-sized Buffer so it no longer
  727. * shares the joined-frame allocation it was sliced from.
  728. *
  729. * After the newline loop over a `Buffer.concat` of the pending chunks, the
  730. * leftover partial line is a `subarray` VIEW onto that concat's backing store.
  731. * A view keeps the ENTIRE backing allocation alive for as long as it is
  732. * retained, so carrying the view forward as the next pending chunk would pin a
  733. * whole large frame's worth of memory behind a tiny trailing fragment — and the
  734. * `pendingBytes` counter, set to the fragment's own length, would no longer
  735. * measure the memory actually held. `Buffer.from` allocates exactly
  736. * `residual.length` bytes and copies, letting the concat allocation be
  737. * collected; an empty residual carries nothing forward.
  738. * @param residual - the leftover slice after the last newline (a view).
  739. * @returns the pending-chunk list to carry forward: `[copy]`, or `[]` when empty.
  740. */
  741. export function detachResidual(residual: Buffer): Buffer[] {
  742. return residual.length > 0 ? [Buffer.from(residual)] : []
  743. }
  744. /** One namespace after seam validation: its callables plus the optional typed-rejection contract. */
  745. interface ValidatedNamespace {
  746. functions: Record<string, PtcBindingFunction>
  747. errorClass?: PtcBindingErrorClass
  748. }
  749. /**
  750. * One in-flight run's host-side state, tracked for disposal so teardown can
  751. * fail every live run as `abort` and AWAIT each child's exit.
  752. */
  753. interface LiveRun {
  754. kill(sig: NodeJS.Signals): void
  755. settle(failure: PtcRunFailure): void
  756. finished: Promise<void>
  757. }
  758. /**
  759. * The experimental {@link PtcRuntime} backend (private, not released) registering as `ptcRuntime`. Every
  760. * cap is validated config; every long-running operation honors the request's
  761. * `AbortSignal`; every disposer awaits child-process exit.
  762. */
  763. export class PythonPtcRuntime extends PtcRuntime {
  764. static Config: z<Config> = z.object({
  765. cpuSeconds: z.number().default(60),
  766. maxWallMs: z.number().default(600_000),
  767. addressSpaceMb: z.number().default(512),
  768. maxLogBytes: z.number().default(65_536),
  769. maxValueBytes: z.number().default(32_768),
  770. graceMs: z.number().default(3_000),
  771. pythonBin: z.string().default('python3'),
  772. })
  773. readonly language = 'python'
  774. readonly isolation = 'process'
  775. private readonly config: ResolvedConfig
  776. private readonly pythonBin: string
  777. // The frame cap this instance enforces: the protocol cap, or the host's
  778. // heap-derived parse ceiling when a constrained heap makes the protocol cap
  779. // unsafe to parse (see {@link hostFrameParseCeiling}). Computed per
  780. // instance so the config gate and the inbound checks agree.
  781. private readonly frameParseCapBytes = hostFrameParseCeiling()
  782. private readonly live = new Set<LiveRun>()
  783. private disposed = false
  784. /* jscpd:ignore-start -- parallel to ptc-runtime-node: sibling backends keep symmetric constructor/teardown/run shapes. */
  785. constructor(ctx: Context, config: Config) {
  786. super(ctx)
  787. // Reject at load on Windows: the bootstrap imports the POSIX-only `resource`
  788. // module for RLIMIT_CPU/RLIMIT_AS, spawns with a positional fd 3, and
  789. // terminates via negative-PID process-group signals — none of which exist
  790. // on Windows. Registering ctx.ptcRuntime there would let assembly succeed
  791. // and defer the failure to the first run. The asymmetry with the worker
  792. // backend is intentional: that backend is cross-platform; this one is not.
  793. if (process.platform === 'win32') {
  794. throw new Error('dsh-ptc-runtime-python: this backend requires a Unix platform (POSIX rlimits, fd-3 stdio, process-group signals); it cannot run on Windows')
  795. }
  796. this.config = config as ResolvedConfig
  797. for (const [key, value] of Object.entries(this.config)) {
  798. if (typeof value === 'number' && !(Number.isFinite(value) && value > 0)) {
  799. throw new Error(`dsh-ptc-runtime-python: config.${key} must be a positive number, got ${String(value)}`)
  800. }
  801. }
  802. // cpuSeconds crosses to the child's setrlimit(RLIMIT_CPU) raw; a float
  803. // raises TypeError inside every child (a late per-run failure). Reject it
  804. // at load. maxLogBytes/maxValueBytes get their own integer gate below (the
  805. // child int()-truncates them, so a float would diverge from the host);
  806. // maxWallMs/graceMs/addressSpaceMb are consumed as numbers where a fraction
  807. // is harmless.
  808. if (!Number.isInteger(this.config.cpuSeconds)) {
  809. throw new Error(`dsh-ptc-runtime-python: config.cpuSeconds must be a positive integer, got ${String(this.config.cpuSeconds)}`)
  810. }
  811. // Finite is not the same as representable as an rlimit. `cpuSeconds` and its
  812. // `+ 1` hard limit both cross to `setrlimit` as integers, and `1e100` clears
  813. // `Number.isInteger` while being far past the safe range, so it cannot round
  814. // -trip: the child sees a different number than was configured. The `+ 1` is
  815. // what gets checked because that is the larger of the two values sent.
  816. if (!Number.isSafeInteger(this.config.cpuSeconds + 1)) {
  817. throw new Error(`dsh-ptc-runtime-python: config.cpuSeconds must be at most ${Number.MAX_SAFE_INTEGER - 1} (it and its +1 hard limit cross to setrlimit as exact integers), got ${String(this.config.cpuSeconds)}`)
  818. }
  819. // `addressSpaceMb` is multiplied by 1 MiB before it is framed, and a large
  820. // finite value overflows to `Infinity` there — which `encodeJsonPlain`
  821. // renders as `null`, so the child receives no limit at all and every run
  822. // ends in a bootstrap exception rather than a load-time configuration error.
  823. // Checking the DERIVED byte count is what catches it; the input itself looks
  824. // ordinary. Safe-integer, not merely finite, since the value must survive
  825. // the JSON round trip exactly.
  826. if (!Number.isSafeInteger(this.config.addressSpaceMb * 1024 * 1024)) {
  827. throw new Error(`dsh-ptc-runtime-python: config.addressSpaceMb must be at most ${Math.floor(Number.MAX_SAFE_INTEGER / (1024 * 1024))} (its byte count crosses the wire as an exact integer), got ${String(this.config.addressSpaceMb)}`)
  828. }
  829. // `pythonBin` reaches `spawn` as the executable path, where values the
  830. // string schema admits fail late and unhelpfully. An empty string makes
  831. // `spawn` throw `ERR_INVALID_ARG_VALUE` synchronously, and an embedded NUL
  832. // throws `ERR_INVALID_ARG_TYPE` — both from inside `run()`, so the method
  833. // REJECTS instead of resolving the `worker-exit` the seam promises for a
  834. // child that cannot start. A basename with no `PATH` match would silently
  835. // fall to execvp's platform default `PATH` under the minimal spawn
  836. // environment (see the resolvePythonBin JSDoc), so it is rejected here
  837. // too. All three are self-contained configuration errors that fail at
  838. // load.
  839. if (this.config.pythonBin === '' || this.config.pythonBin.includes('\0')) {
  840. throw new Error(`dsh-ptc-runtime-python: config.pythonBin must be a non-empty path without NUL bytes, got ${JSON.stringify(this.config.pythonBin)}`)
  841. }
  842. // `maxWallMs` and `graceMs` are armed with setTimeout, which clamps any
  843. // delay past MAX_TIMER_DELAY_MS to 1 ms without a word — turning a
  844. // generous ceiling into an instant timeout and a generous grace period into
  845. // an instant SIGKILL. `graceMs` is checked against the margin the
  846. // close-deadline adds on top, since that sum is what gets armed.
  847. if (this.config.maxWallMs > MAX_TIMER_DELAY_MS) {
  848. throw new Error(`dsh-ptc-runtime-python: config.maxWallMs must not exceed ${MAX_TIMER_DELAY_MS} (setTimeout clamps a larger delay to 1ms), got ${String(this.config.maxWallMs)}`)
  849. }
  850. if (this.config.graceMs + CLOSE_REAP_MARGIN_MS > MAX_TIMER_DELAY_MS) {
  851. throw new Error(`dsh-ptc-runtime-python: config.graceMs must not exceed ${MAX_TIMER_DELAY_MS - CLOSE_REAP_MARGIN_MS} (its close deadline adds ${CLOSE_REAP_MARGIN_MS}ms, and setTimeout clamps a larger delay to 1ms), got ${String(this.config.graceMs)}`)
  852. }
  853. // The output caps are budgets for a payload that has to cross fd 3 inside
  854. // one frame, and the framing ceiling is fixed. A cap above what a frame can
  855. // carry is unsatisfiable: a completion or log entry that the cap admits
  856. // arrives as an over-ceiling frame and fails the run as `worker-exit`
  857. // instead of the `output-limit` the cap describes — a silent inversion, so
  858. // it fails at load. Both budgets are metered in SERIALIZED (JSON-escaped)
  859. // bytes — the host log ledger charges the serialized cost via
  860. // `jsonStringCostUpTo`, which walks to the cap without allocating the escaped
  861. // copy, `checkDoneValue` measures the escaped form, and the producing-side
  862. // `_cap_message` in the child also caps by serialized cost (which is why a
  863. // capped diagnostic still fits its frame) — so a payload admitted under the
  864. // cap occupies at most `cap + envelope` bytes on the wire; escaping is
  865. // already inside the charge and must not be multiplied in again. The
  866. // receive-side `capMessage` backstop is the one exception to this argument:
  867. // it bills a forged `done.error.message` by RAW bytes, but that output goes
  868. // into `PtcRunResult.error.message` and never re-crosses a frame-bounded
  869. // channel, so it is not part of the wire-width bound (see its JSDoc). The
  870. // admissible cap is therefore `parse-cap - envelope`: the receive path
  871. // rejects raw frames past the effective parse cap (`frameParseCapBytes` —
  872. // the protocol cap, or the host's heap-derived ceiling when a constrained
  873. // heap makes the protocol cap unsafe to parse; see hostFrameParseCeiling)
  874. // before decoding (the run settles as a worker-exit; a hostile
  875. // compact-wide-frame OOM guard), so a budget must not exceed what an
  876. // honest child's frame can actually carry through that parser.
  877. for (const key of ['maxLogBytes', 'maxValueBytes'] as const) {
  878. // Require an integer: the child reads these budgets through `int(...)`,
  879. // which silently floors a float, so `maxLogBytes: 3.5` would truncate at 3
  880. // bytes child-side while the host meters and marks at 3.5 — the two sides
  881. // enforcing different public config. Reject the float at load, as the
  882. // Node backend does for its byte budgets.
  883. if (!Number.isInteger(this.config[key])) {
  884. throw new Error(`dsh-ptc-runtime-python: config.${key} must be a positive integer (the child reads it as an int, so a float diverges from the host), got ${String(this.config[key])}`)
  885. }
  886. const limit = this.frameParseCapBytes - FRAME_ENVELOPE_BYTES
  887. if (this.config[key] > limit) {
  888. // Only a host whose heap is below the protocol cap reaches the
  889. // heap-constrained note; the constrained-heap rejection is exercised
  890. // by the subprocess load test, but subprocess runs are not
  891. // coverage-instrumented, so the note's arm is not schedulable from the
  892. // instrumented suite (whose heap never binds).
  893. /* v8 ignore next -- the heap-constrained message arm needs a host heap below the protocol cap. */
  894. const heapNote = this.frameParseCapBytes < FRAME_PARSE_CAP_BYTES ? ` — this host's heap limits the parse to ${this.frameParseCapBytes} bytes, so the protocol cap of ${FRAME_PARSE_CAP_BYTES} would be unsafe` : ''
  895. throw new Error(`dsh-ptc-runtime-python: config.${key} must not exceed ${limit} (a payload that large cannot cross the fd-3 frame PARSER, which rejects raw frames past ${this.frameParseCapBytes} bytes before decoding to bound host memory${heapNote} — a larger budget would admit a config whose honest child frames the host then rejects as a worker-exit), got ${String(this.config[key])}`)
  896. }
  897. // Reject a log budget too small to honor: the truncation marker alone
  898. // must serialize within the budget, or a marker-only truncated run
  899. // returns more than the configured cap. (With admitted entries the
  900. // marker is envelope, so the serialized logs run to
  901. // `maxLogBytes + marker + envelope`.)
  902. if (key === 'maxLogBytes' && this.config[key] < MIN_LOG_BYTES) {
  903. throw new Error(`dsh-ptc-runtime-python: config.maxLogBytes must be at least ${MIN_LOG_BYTES} (a smaller budget cannot serialize the truncation marker itself, so a marker-only truncated run would return more than the configured cap), got ${String(this.config[key])}`)
  904. }
  905. }
  906. // The child builds, charges, and frames a `maxLogBytes` log entry or a
  907. // `maxValueBytes` completion value under `RLIMIT_AS`, and both paths trigger
  908. // on CHARACTER count against a serialized-BYTE budget. An astral character is
  909. // one character but four bytes of `str` storage and four UTF-8 bytes, so a
  910. // budget's worth of them peaks at three simultaneous ~4x copies (the caller's
  911. // write argument, the line slice or joined pending handed to push, and the
  912. // encode push takes to charge and ship it). A budget approaching
  913. // `addressSpaceMb` therefore makes a LEGITIMATE near-budget output breach the
  914. // address space and die as `worker-exit` instead of truncating (log) or
  915. // failing as `output-limit` (value). Metering every child write against the
  916. // address space at runtime is the wrong fix — an exact serialized-cost check
  917. // is either a full encode (the allocation being avoided) or a per-character
  918. // Python loop that burns the CPU budget — so the incompatible pair is rejected
  919. // at load: each budget times the worst-case multiple must fit the address
  920. // space. Checked on every platform, not just where `RLIMIT_AS` is enforced:
  921. // the incompatibility is a property of the config values, and the child OOMs
  922. // on a Linux deployment regardless of the host that assembled the config, so a
  923. // uniform load-time rejection is the fail-loud contract (Darwin skips only the
  924. // runtime `setrlimit`).
  925. const addressSpaceBytes = this.config.addressSpaceMb * 1024 * 1024
  926. // Room left for the peak output allocation after the interpreter's own fixed
  927. // footprint. A budget must fit MULTIPLE times over into THIS, not the whole
  928. // address space, so a budget sized right at `addressSpaceMb / MULTIPLE` — which
  929. // the multiple alone would admit — cannot leave the peak plus the interpreter
  930. // over the limit.
  931. const budgetableBytes = addressSpaceBytes - INTERPRETER_BASELINE_BYTES
  932. // The largest budget that fits: the peak (budget * MULTIPLE) must leave room,
  933. // so a budget whose peak exactly equals `budgetableBytes` is rejected — that
  934. // peak plus the reserved baseline is the whole address space, the RLIMIT_AS
  935. // edge. `ceil(budgetableBytes / MULTIPLE) - 1` is the last integer strictly
  936. // under `budgetableBytes / MULTIPLE`.
  937. // Reject a too-small address space on its own terms FIRST. Once
  938. // `budgetableBytes` is zero or negative no budget can pass, and the loop
  939. // below would report "a limit of -1" (or -2796203 at addressSpaceMb 32) while
  940. // naming `maxLogBytes` -- pointing the operator at the knob that is not the
  941. // problem. The baseline is what `addressSpaceMb` must clear here.
  942. if (budgetableBytes <= 0) {
  943. throw new Error(`dsh-ptc-runtime-python: config.addressSpaceMb must exceed the ${INTERPRETER_BASELINE_BYTES}-byte interpreter baseline with room for the output budgets, so the child has address space left to build and encode them; got ${String(this.config.addressSpaceMb)} MiB (${addressSpaceBytes} bytes)`)
  944. }
  945. const admissibleBudget = Math.ceil(budgetableBytes / OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE) - 1
  946. for (const key of ['maxLogBytes', 'maxValueBytes'] as const) {
  947. if (this.config[key] * OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE >= budgetableBytes) {
  948. throw new Error(`dsh-ptc-runtime-python: config.${key} times the ${OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE}x worst-case Unicode expansion must fit within the ${budgetableBytes} bytes left after the ${INTERPRETER_BASELINE_BYTES}-byte interpreter baseline within the ${addressSpaceBytes}-byte addressSpaceMb, so a near-budget output truncates rather than breaching RLIMIT_AS as worker-exit; got ${String(this.config[key])} against a limit of ${admissibleBudget}`)
  949. }
  950. }
  951. // Resolve and validate the executable ONCE, after the pure config checks.
  952. // Re-resolving a basename in each run would let a later PATH change silently
  953. // switch interpreters, while an unchecked explicit path would turn
  954. // self-contained misconfiguration into a late worker-exit. A missing or
  955. // unsupported interpreter is a load failure. Later filesystem mutation is
  956. // outside config validation; a missing executable settles as worker-exit.
  957. const pythonBin = resolvePythonBin(this.config.pythonBin)
  958. if (pythonBin === undefined) {
  959. const explicit = isAbsolute(this.config.pythonBin) || this.config.pythonBin.includes('/')
  960. throw new Error(`dsh-ptc-runtime-python: config.pythonBin ${JSON.stringify(this.config.pythonBin)} ${explicit ? 'is not an executable regular file' : 'does not resolve on PATH'}`)
  961. }
  962. validatePythonBin(pythonBin)
  963. this.pythonBin = pythonBin
  964. ctx.effect(() => () => this.teardown(), 'python ptc-runtime teardown')
  965. }
  966. /**
  967. * Dispose to quiescence: fail every in-flight run as aborted and AWAIT each
  968. * child's exit so no subprocess that stays in the child's process group
  969. * outlives the fiber. A descendant that escaped the group with `setsid()` /
  970. * `start_new_session=True` is unreachable by `kill(-pid)` and is the documented
  971. * exception (see the package README's Known Limitations); the process-group
  972. * teardown reaps everything that stays in the group.
  973. */
  974. private async teardown(): Promise<void> {
  975. this.disposed = true
  976. const runs = [...this.live]
  977. for (const run of runs) run.settle({ kind: 'abort', message: 'runtime disposed' })
  978. // Awaiting `finished` is also what clears staging: that promise resolves
  979. // inside the run's own `settle`, which removes its directory first. So there
  980. // is deliberately no sweep here — a second pass could only ever find an
  981. // empty set, and an unreachable cleanup path is worse than none, since it
  982. // reads as the real guarantee while never running.
  983. await Promise.all(runs.map(run => run.finished))
  984. }
  985. /**
  986. * Resolve directory and the experimental provider's configured wall deadline.
  987. * @param request - Program inputs; explicit sandbox or timeout overrides are unsupported.
  988. * @returns Complete inputs for the provider's run method.
  989. * @throws When a requested override is unsupported or cwd is relative.
  990. */
  991. resolve(request: PtcRunRequest): PtcRunSpec {
  992. if (request.sandboxPolicy !== undefined) throw new Error('dsh-ptc-runtime-python: sandbox policy is unsupported')
  993. if (request.timeoutMs !== undefined) throw new Error('dsh-ptc-runtime-python: per-call timeout is unsupported')
  994. const cwd = request.cwd ?? process.cwd()
  995. if (!isAbsolute(cwd)) throw new Error('dsh-ptc-runtime-python: cwd must be absolute')
  996. return { ...request, cwd, timeoutMs: this.config.maxWallMs }
  997. }
  998. /**
  999. * Execute a resolved Python program; this experimental provider has no file confinement.
  1000. * @param request - Resolved cwd, provider deadline, program and bindings.
  1001. * @returns Captured output and the program outcome.
  1002. */
  1003. async run(request: PtcRunSpec): Promise<PtcRunResult> {
  1004. if (request.sandboxPolicy !== undefined || request.timeoutMs !== this.config.maxWallMs) throw new Error('dsh-ptc-runtime-python: unsupported execution policy or timeout')
  1005. if (this.disposed) throw new Error('dsh-ptc-runtime-python: run() after disposal')
  1006. const bindings = this.validateBindings(request)
  1007. if (request.signal?.aborted) {
  1008. return { logs: [], error: { kind: 'abort', message: messageOf(request.signal.reason) } }
  1009. }
  1010. let bootstrapPath: string
  1011. try {
  1012. // The interpreter is an external process, so the entry script has to sit
  1013. // on the real filesystem; see materializePyScripts. One copy PER RUN,
  1014. // synchronously, so no async boundary opens before `execute` registers the
  1015. // run and installs the abort listener.
  1016. bootstrapPath = materializePyScripts()
  1017. } catch (error: unknown) {
  1018. // A full or read-only temp filesystem, or a packaged asset the deployment
  1019. // failed to ship, is a SUBSTRATE failure — the same class as a child that
  1020. // cannot start. The seam permits rejection only for misuse, so this
  1021. // resolves as `worker-exit` rather than throwing out of `run()`.
  1022. return { logs: [], error: { kind: 'worker-exit', message: `failed to stage the python bootstrap: ${messageOf(error)}` } }
  1023. }
  1024. return await this.execute(request, bindings, bootstrapPath)
  1025. }
  1026. /* jscpd:ignore-end */
  1027. /**
  1028. * Reject (seam misuse) malformed binding namespaces: non-identifier or
  1029. * reserved globals/error classes, duplicates, and colliding or
  1030. * runtime-owned injected globals.
  1031. */
  1032. private validateBindings(request: PtcRunRequest): Map<string, ValidatedNamespace> {
  1033. const bindings = new Map<string, ValidatedNamespace>()
  1034. // Every name the bootstrap injects into the program's one global namespace:
  1035. // namespace globals plus error-class names. They must be a collision-free
  1036. // set that avoids the runtime's own slots, or a later injection silently
  1037. // overwrites an earlier one (or the completion/builtins slot) and the run
  1038. // fails obscurely at execution time.
  1039. const injectedGlobals = new Set<string>()
  1040. const claimGlobal = (name: string, role: string): void => {
  1041. if (RUNTIME_OWNED_GLOBALS.has(name)) {
  1042. throw new Error(`dsh-ptc-runtime-python: ${role} ${JSON.stringify(name)} collides with a runtime-owned global`)
  1043. }
  1044. if (injectedGlobals.has(name)) {
  1045. throw new Error(`dsh-ptc-runtime-python: ${role} ${JSON.stringify(name)} collides with another injected global`)
  1046. }
  1047. injectedGlobals.add(name)
  1048. }
  1049. for (const namespace of request.bindings) {
  1050. // Snapshot the caller-supplied fields into plain values ONCE. The
  1051. // namespace and errorClass objects may expose `global`/`name`/
  1052. // `memberNameProperty` through getters: validation reads each several
  1053. // times, and the ORIGINAL errorClass object would otherwise be retained
  1054. // for the boot frame, whose JSON.stringify re-reads it after validation.
  1055. // A getter that changes or throws on a later read would turn the
  1056. // seam-misuse rejection into a worker-exit (or inject a different name
  1057. // than validation approved); reading each field once here and keeping
  1058. // the plain copy makes validation and the boot frame agree.
  1059. const global = namespace.global
  1060. if (!IDENTIFIER.test(global) || RESERVED_NAMES.has(global)) {
  1061. throw new Error(`dsh-ptc-runtime-python: binding global ${JSON.stringify(global)} is not a usable Python identifier`)
  1062. }
  1063. if (bindings.has(global)) {
  1064. throw new Error(`dsh-ptc-runtime-python: duplicate binding global ${JSON.stringify(global)}`)
  1065. }
  1066. claimGlobal(global, 'binding global')
  1067. // The error class becomes a program global and its member property an
  1068. // attribute name, so both face the Python identifier rules; the member
  1069. // additionally must be assignable on a BaseException instance.
  1070. const errorClass = namespace.errorClass
  1071. let validatedErrorClass: PtcBindingErrorClass | undefined
  1072. if (errorClass) {
  1073. const name = errorClass.name
  1074. const memberNameProperty = errorClass.memberNameProperty
  1075. if (!IDENTIFIER.test(name) || RESERVED_NAMES.has(name)) {
  1076. throw new Error(`dsh-ptc-runtime-python: errorClass.name ${JSON.stringify(name)} is not a usable Python identifier`)
  1077. }
  1078. // Any non-empty own attribute name is settable via setattr (the
  1079. // program reads exotic names like `tool-name` with getattr), matching
  1080. // the seam contract and the Node backend — only the seam-excluded
  1081. // and protocol-reserved members below are refused.
  1082. if (memberNameProperty.length === 0) {
  1083. throw new Error('dsh-ptc-runtime-python: errorClass.memberNameProperty must be a non-empty attribute name')
  1084. }
  1085. if (EXCEPTION_RESERVED_MEMBERS.has(memberNameProperty) || DUNDER.test(memberNameProperty)) {
  1086. throw new Error(`dsh-ptc-runtime-python: errorClass.memberNameProperty ${JSON.stringify(memberNameProperty)} is a reserved error member and cannot be assigned`)
  1087. }
  1088. claimGlobal(name, 'errorClass.name')
  1089. validatedErrorClass = { name, memberNameProperty }
  1090. }
  1091. // Snapshot the callables into a plain own-property record before the
  1092. // child can dispatch. `namespace.functions` is caller-supplied, so it may
  1093. // expose members through getters or a Proxy; reading one of them inside
  1094. // the fd-3 `data` callback would throw OUTSIDE the dispatcher's try and
  1095. // terminate the host (defensive-patterns contain-callback-exceptions).
  1096. // Reading every member here, in run()'s synchronous validation segment,
  1097. // turns that throw into the seam-misuse rejection run() reserves for
  1098. // malformed bindings. The snapshot is also the single key set the boot
  1099. // frame advertises AND dispatch reads, so a getter whose keys differ
  1100. // between reads cannot desynchronize the child's allowed names from what
  1101. // the host will actually call. The record is null-prototype: the seam
  1102. // contract treats member names like `__proto__` or `constructor` as
  1103. // ordinary own properties, and a plain `{}` assignment of `__proto__`
  1104. // would hit the prototype setter instead of creating the own property.
  1105. const functions = Object.create(null) as Record<string, PtcBindingFunction>
  1106. for (const name of Object.keys(namespace.functions)) {
  1107. // Only callables enter the snapshot: a getter exposing a non-function
  1108. // member would otherwise assign a value the dispatcher's `typeof fn
  1109. // !== 'function'` check rejects anyway, and keeping it out of the
  1110. // snapshot keeps the boot frame's name list and the dispatch key set
  1111. // one and the same.
  1112. const fn = namespace.functions[name]
  1113. if (typeof fn === 'function') functions[name] = fn
  1114. }
  1115. bindings.set(global, { functions, ...validatedErrorClass ? { errorClass: validatedErrorClass } : {} })
  1116. }
  1117. return bindings
  1118. }
  1119. /** Spawn the child for one validated run and drive it to settlement. */
  1120. private execute(
  1121. request: PtcRunSpec,
  1122. bindings: Map<string, ValidatedNamespace>,
  1123. bootstrapPath: string,
  1124. ): Promise<PtcRunResult> {
  1125. // This run's own staging directory, removed at settlement.
  1126. const bootstrapDir = dirname(bootstrapPath)
  1127. // Explicit pipe count of 4 puts the framed-JSON channel at fd 3 in the child.
  1128. // The constructor resolved and validated the interpreter once; runs keep that
  1129. // exact path even if the host later changes PATH.
  1130. // `spawn` can throw SYNCHRONOUSLY — a descriptor-exhausted host (EMFILE) or a
  1131. // libuv-level failure surfaces here, before the Promise executor and its
  1132. // settlement path exist. Left uncaught it would REJECT run() (the seam
  1133. // permits rejection only for misuse) and strand this run's staging directory,
  1134. // which only settle() removes. Catch it, unlink the directory, and resolve a
  1135. // `worker-exit` — the same class as the async ENOENT `error` event below.
  1136. let child: ChildProcessWithoutNullStreams
  1137. let proto: Duplex | null
  1138. try {
  1139. // `-u` keeps the interpreter's own stdout/stderr UNBUFFERED: a program
  1140. // that writes through `sys.__stdout__`/`sys.__stderr__` (or C-stdio
  1141. // layered on the same fds) must have those bytes visible to the host's
  1142. // stray capture immediately — a block-buffered wrapper would otherwise
  1143. // hold them until an explicit flush, and the host SIGTERMs the child
  1144. // right after the done frame, before any finalization-time flush could
  1145. // run. The `_LogStream` replacement of `sys.stdout`/`sys.stderr` is
  1146. // unaffected (it is a Python object, not the C-level stdio buffer).
  1147. child = spawn(this.pythonBin, ['-u', '-I', bootstrapPath], {
  1148. cwd: request.cwd,
  1149. // Preserve only the platform temp directory. macOS system Python emits a
  1150. // startup warning when TMPDIR is absent; ambient credentials, PATH, HOME,
  1151. // and other host state remain unavailable to model code.
  1152. env: pythonEnvironment(),
  1153. detached: true, // Own process group — kill(-pid, sig) reaches subprocesses the model program spawns.
  1154. stdio: ['pipe', 'pipe', 'pipe', 'pipe'],
  1155. })
  1156. // Fd 3 is a duplex pipe carrying protocol frames. Node types extra stdio
  1157. // entries as `Stream | null`; the runtime shape with `'pipe'` is a duplex,
  1158. // so we narrow at the boundary rather than smearing casts below. Stdout
  1159. // and stderr are guaranteed non-null under `'pipe'` and typed as such.
  1160. proto = child.stdio[3] as Duplex | null
  1161. /* v8 ignore next 3 -- `'pipe'` stdio always populates fd 3; guarding Node's `Stream | null` typing widening. */
  1162. if (proto === null) {
  1163. throw new Error('dsh-ptc-runtime-python: python subprocess spawned without a fd-3 pipe')
  1164. }
  1165. // Close the host's stdin write handle immediately: the program is an
  1166. // async body that reads nothing from fd 0, and a live pipe here would
  1167. // hold a host-side handle open past the run — a setsid-escaped descendant
  1168. // inheriting fd 0 would keep the host process from exiting even after the
  1169. // closeDeadline forced settlement. The child (and any descendant) reads
  1170. // EOF on fd 0 instead, and no host handle survives.
  1171. // oxlint-disable-next-line typescript/no-unnecessary-condition -- the boot-write-failure fake child has no stdin.
  1172. child.stdin?.destroy()
  1173. } catch (error: unknown) {
  1174. try {
  1175. rmSync(bootstrapDir, { recursive: true, force: true })
  1176. } catch {
  1177. // Same swallow as settle()'s removal: `force` already absorbs a missing
  1178. // directory, so only a filesystem-level refusal reaches here, and the
  1179. // staging copy holds nothing but two checked-in scripts.
  1180. }
  1181. return Promise.resolve({ logs: [], error: { kind: 'worker-exit' as const, message: `python spawn error: ${messageOf(error)}` } })
  1182. }
  1183. return new Promise<PtcRunResult>((resolve) => {
  1184. let settled = false
  1185. const logs: string[] = []
  1186. // An unterminated line flushed with the `open` flag: the next log frame
  1187. // appends to it (no fake newline between entries), and finish() pushes
  1188. // the residual if the run ends with it still open. Held as a fragment
  1189. // ARRAY, so k tiny open frames cost O(k) — re-joining and re-walking the
  1190. // whole held text per frame would be O(k * budget).
  1191. let openParts: string[] = []
  1192. // Past MAX_PENDING_CHUNKS, the held fragments are coalesced into sealed
  1193. // blocks (mirroring the fd-3 reader's `blocks` and the stray capture's
  1194. // seal): each fragment is a distinct array slot plus string object
  1195. // header — ~30x overhead the byte cap cannot see — so a budget-sized
  1196. // single-character open flood would otherwise accumulate thousands of
  1197. // slots. Sealing bounds the live fragment count exactly like the
  1198. // sibling paths; the merge reads sealed + current fragments. A block
  1199. // ARRAY (not one repeated string concat) matches the sibling shape and
  1200. // avoids depending on V8 ConsString amortization.
  1201. let openSealed: string[] = []
  1202. // Every truncation arm funnels here: the committed open prefix was
  1203. // ALREADY billed, so it is pushed BEFORE the marker — a flushed line is
  1204. // never lost (only the marker stays last), and no ledger re-charge
  1205. // happens. openParts is emptied here, so no later arm or finish() sees
  1206. // it.
  1207. const truncateLogs = (): void => {
  1208. logsTruncated = true
  1209. if (openSealed.length > 0 || openParts.length > 0) {
  1210. logs.push(openSealed.join('') + openParts.join(''))
  1211. openSealed = []
  1212. openParts = []
  1213. }
  1214. logs.push(logTruncationMarker(this.config.maxLogBytes))
  1215. clearStray(strayOut)
  1216. clearStray(strayErr)
  1217. }
  1218. // One host-side ledger covers normal frames, forged frames, and stray stdout bytes.
  1219. // The ledger starts one byte below maxLogBytes: each entry is charged its
  1220. // JSON-string cost plus one separator byte, and the serialized outer logs
  1221. // array adds one more byte of envelope (two brackets and n-1 commas over n
  1222. // entries' separators), so a result that exactly exhausts the ledger
  1223. // serializes to exactly maxLogBytes; WITHOUT the reserved byte it would
  1224. // serialize to maxLogBytes + 1. Reserving that byte keeps an admitted
  1225. // result within the configured cap; the truncation-marker entry is
  1226. // envelope, not payload, and rides uncharged.
  1227. let logBudget = this.config.maxLogBytes - 1
  1228. let logsTruncated = false
  1229. // Drop a pipe's buffered stray output wholesale: once the ledger has
  1230. // truncated, every byte of it would be no-op'd by admit(), so retaining
  1231. // it (and later Buffer.concat+decoding it in flushStray) would spend host
  1232. // memory on output that can never be admitted. Called from every arm that
  1233. // marks the ledger truncated — admit()'s two ceilings and the child-marker
  1234. // frame arm — so the end-path flushStray sees empty buffers and exits.
  1235. const clearStray = (stray: StrayBuffer): void => {
  1236. stray.chunks = []
  1237. stray.blocks = []
  1238. stray.cost = 0
  1239. stray.utf8 = { expected: 0, width: 0, lowerFirst: 0, upperFirst: 0 }
  1240. }
  1241. const admit = (text: string): void => {
  1242. // Post-truncation admits are no-ops: once the ledger has truncated, the
  1243. // marker is the last entry. Reachable within one `data` callback — a
  1244. // chunk carrying two newline-terminated lines where the first exhausts
  1245. // the budget hits this on the second — so it is a measured branch.
  1246. if (logsTruncated) return
  1247. // Each entry is charged its SERIALIZED cost — JSON.stringify's quotes
  1248. // and escapes plus one separator byte — because the seam bounds the
  1249. // serialized outer logs payload, and control characters expand
  1250. // several-fold under JSON escaping (a "\x00" flood would otherwise
  1251. // admit 6x its charge). The charge also puts a floor under an empty
  1252. // entry (its two quotes plus separator), so a `while True: print()`
  1253. // flood of zero-byte lines exhausts the ledger instead of growing the
  1254. // retained array without ever touching the budget. The one fixed
  1255. // truncation-marker entry is envelope, not payload, and rides
  1256. // uncharged.
  1257. //
  1258. // Cheap lower bound FIRST, before the escaped copy exists: every
  1259. // UTF-16 code unit costs at least one serialized byte (an ASCII
  1260. // character is one byte; a control character is six as `\uXXXX`; a
  1261. // non-ASCII BMP character is two or three; each half of a surrogate
  1262. // pair contributes two of the four bytes its code point encodes to),
  1263. // and the JSON form adds two quotes on top of the separator byte. So
  1264. // `text.length + 3` never exceeds the true cost, and a forged `log`
  1265. // frame carrying a control-heavy string anywhere below the 64 MiB
  1266. // frame parse cap truncates here instead of allocating a
  1267. // hundreds-of-megabytes escaped copy under a small maxLogBytes.
  1268. if (text.length + 3 > logBudget) {
  1269. // Release the buffered stray pipes: their bytes can never be
  1270. // admitted now (see clearStray).
  1271. truncateLogs()
  1272. return
  1273. }
  1274. // Past the lower bound, measure the exact serialized cost without
  1275. // allocating the escaped copy: `jsonStringCostUpTo` walks to the cap and
  1276. // stops, so even a near-budget control-char-dense line never materializes
  1277. // a sixfold-inflated `JSON.stringify` result. `+ 1` for the separator.
  1278. const measured = jsonStringCostUpTo(text, logBudget - 1)
  1279. if (measured === undefined) {
  1280. truncateLogs()
  1281. return
  1282. }
  1283. logBudget -= measured + 1
  1284. logs.push(text)
  1285. }
  1286. // Stray-byte capture: anything the child writes to its stdout/stderr
  1287. // (native prints, C-extension writes) still counts against the ledger.
  1288. //
  1289. // Output is admitted per LINE, not per transport chunk. `logs` entries
  1290. // are joined with `\n` downstream (PTC mode), so each entry must be one
  1291. // line: pushing a raw `data` chunk would turn every arbitrary pipe-read
  1292. // boundary into a model-visible newline, so a single 200 KiB native write
  1293. // split across pipe reads would read back with spurious line breaks. The
  1294. // child's own `log` frames are already line-granular; stray capture
  1295. // matches them by splitting on `\n`.
  1296. //
  1297. // Buffered as raw `Buffer` chunks with a running SERIALIZED-cost counter,
  1298. // exactly like the fd-3 reader below and for the same reasons: a string
  1299. // `+=` accumulator re-copies the whole residual on every pipe chunk
  1300. // (quadratic on a large newline-free write), and scanning it from index 0
  1301. // each chunk is a second quadratic. Appending a chunk is O(1); the split
  1302. // happens only when a `\n` actually arrived. A newline never appears inside
  1303. // a UTF-8 multibyte sequence (continuation bytes are 0x80–0xBF), so
  1304. // splitting on the raw 0x0a byte and decoding each complete line is safe
  1305. // without a streaming decoder — a line's bytes are whole by construction.
  1306. //
  1307. // `chunks` also seals into `blocks` past MAX_PENDING_CHUNKS, mirroring the
  1308. // fd-3 reader: without it a program pacing one-byte newline-free
  1309. // `os.write`s accumulates one Buffer object per write, and the object plus
  1310. // backing-store overhead — which no byte or cost count sees — exhausts the
  1311. // host heap far below the budget. Sealing bounds the live object count.
  1312. interface StrayBuffer { chunks: Buffer[]; blocks: Buffer[]; cost: number; utf8: Utf8CostState }
  1313. const strayOut: StrayBuffer = { chunks: [], blocks: [], cost: 0, utf8: { expected: 0, width: 0, lowerFirst: 0, upperFirst: 0 } }
  1314. const strayErr: StrayBuffer = { chunks: [], blocks: [], cost: 0, utf8: { expected: 0, width: 0, lowerFirst: 0, upperFirst: 0 } }
  1315. const captureStray = (stray: StrayBuffer, chunk: Buffer): void => {
  1316. // Once the ledger has truncated, stop buffering: admit() is a no-op past
  1317. // that point, so continuing to accumulate would retain host memory for
  1318. // output that can never be admitted.
  1319. if (logsTruncated) return
  1320. stray.chunks.push(chunk)
  1321. // Track SERIALIZED cost, not raw bytes: a control-char-dense residual
  1322. // (a NUL or illegal-UTF-8 flood) serializes several-fold, so a raw-byte
  1323. // threshold would let it grow to the full budget's worth of RAW bytes
  1324. // before flushing. `accrueStrayCost` decodes UTF-8 structurally across
  1325. // chunks (via `stray.utf8`) so a byte that renders as U+FFFD is charged
  1326. // its three serialized bytes, not one.
  1327. stray.cost += accrueStrayCost(chunk, stray.utf8)
  1328. // Bound the live fragment count (see the seal rationale above), before
  1329. // any concat so an over-count payload is never copied whole first.
  1330. if (stray.chunks.length >= MAX_PENDING_CHUNKS) {
  1331. stray.blocks.push(Buffer.concat(stray.chunks))
  1332. stray.chunks = []
  1333. }
  1334. if (chunk.includes(0x0a)) {
  1335. let buffered = Buffer.concat(stray.blocks.length > 0 ? [...stray.blocks, ...stray.chunks] : stray.chunks)
  1336. stray.blocks = []
  1337. let newline: number
  1338. while ((newline = buffered.indexOf(0x0a)) >= 0) {
  1339. admit(buffered.subarray(0, newline).toString('utf8'))
  1340. buffered = buffered.subarray(newline + 1)
  1341. }
  1342. // Carry the residual as a fresh right-sized copy, not the subarray view
  1343. // (which would pin the whole concat allocation). See detachResidual.
  1344. // The residual begins at a character boundary (a newline is never
  1345. // inside a multibyte sequence), so its cost and UTF-8 state recompute
  1346. // cleanly from a fresh walk.
  1347. // A line admitted inside the loop may have exhausted the ledger and
  1348. // cleared this pipe (see clearStray); the re-retain below must not
  1349. // resurrect the doomed residual.
  1350. // oxlint-disable-next-line typescript/no-unnecessary-condition -- admit() (a closure) sets it.
  1351. if (logsTruncated) return
  1352. stray.chunks = detachResidual(buffered)
  1353. stray.utf8 = { expected: 0, width: 0, lowerFirst: 0, upperFirst: 0 }
  1354. stray.cost = accrueStrayCost(buffered, stray.utf8)
  1355. }
  1356. // Newline-free residual is bounded by the ledger, not left to grow with
  1357. // the stream: an `os.write(1, b"A"*N)` flood carrying no newline would
  1358. // otherwise accumulate N bytes in host memory before `end`. The bound is
  1359. // on the COMBINED pending cost of both pipes, not each alone: stdout and
  1360. // stderr share one `logBudget`, so checking each against the full budget
  1361. // independently would let both retain nearly a budget's worth at once —
  1362. // ~2x peak, up to ~512 MiB near the ceiling — before either flushed.
  1363. // When the sum would cross the budget, flush both now. admit() charges
  1364. // the exact serialized cost, truncates, and marks the ledger, and the
  1365. // truncation short-circuit above stops buffering on the next chunk.
  1366. // `+ 3` covers the two quotes and one separator admit adds. The two
  1367. // pipes are independent OS streams whose `data` events already interleave
  1368. // nondeterministically with each other and with the child's own fd-3
  1369. // `log` frames, so `logs` carries no cross-pipe ordering guarantee to
  1370. // preserve here; a fixed drain order is as valid as any.
  1371. // Flushing is NOT a stream end: a multibyte UTF-8 character can be split
  1372. // across pipe `data` chunks, so the residual may end mid-sequence. A
  1373. // budget-triggered flush must decode only the complete prefix and carry
  1374. // the incomplete tail forward (≤3 bytes) on the same pipe's residual —
  1375. // decoding it here would render a legal character as U+FFFD in a released
  1376. // entry (see `flushStray`). This is unlike the `end`/closeDeadline paths
  1377. // below, where a trailing incomplete sequence is genuinely truncated input
  1378. // and U+FFFD is honest.
  1379. if (strayOut.cost + strayErr.cost + 3 > logBudget) {
  1380. flushStray(strayOut, true)
  1381. flushStray(strayErr, true)
  1382. }
  1383. }
  1384. // Flush a pipe's residual into `logs`. Called on the combined-budget
  1385. // threshold above, on the pipe's `end` (normal drain), and — for the
  1386. // setsid-escapee path where destroy() forces settlement without an `end` —
  1387. // explicitly in the closeDeadline handler. Idempotent: it clears what it
  1388. // admits, so a later flush is a no-op, and it returns early on an empty
  1389. // buffer so flushing the sibling that had nothing pending is a no-op. The
  1390. // `chunks`/`blocks` guard is the only emptiness check needed — `data` never
  1391. // emits a zero-length Buffer, so a non-empty fragment list always decodes
  1392. // to a non-empty tail.
  1393. //
  1394. // `retainPartialTail` is true only on the budget-triggered path: there the
  1395. // residual can end at an ARBITRARY pipe boundary, so if the incomplete
  1396. // trailing bytes of a UTF-8 lead sequence are pending (`stray.utf8.expected
  1397. // > 0`), they are withheld from the decode and re-carried on `chunks` for a
  1398. // later chunk to complete — decoding them here would render a LEGAL,
  1399. // un-finished character as U+FFFD in an admitted entry, and the next chunk's
  1400. // bytes would then each independently break into more U+FFFD. The withheld
  1401. // tail is `stray.utf8.width - stray.utf8.expected` bytes (the lead plus the
  1402. // continuations consumed so far), at most 3; `stray.utf8` is reset and the
  1403. // withheld tail re-accrued so the next chunk continues the walk correctly.
  1404. // The `end`/closeDeadline paths pass `false`: there a trailing incomplete
  1405. // sequence is real truncated input and the U+FFFD is the honest render.
  1406. function flushStray(stray: StrayBuffer, retainPartialTail?: boolean): void {
  1407. if (stray.chunks.length === 0 && stray.blocks.length === 0) return
  1408. // Concatenate the sealed blocks and the current-chunk residual together
  1409. // unconditionally (no `blocks.length > 0` ternary): a flush can run with
  1410. // either or both present, and a branch on their presence would need a
  1411. // test that flushes exactly at a seal boundary.
  1412. let full = Buffer.concat([...stray.blocks, ...stray.chunks])
  1413. // A budget flush landing exactly between a lead byte and its
  1414. // still-pending continuation requires the combined-cost threshold to trip
  1415. // on a specific mid-multibyte pipe boundary — not deterministically
  1416. // schedulable through the black-box seam, which observes only complete
  1417. // entries. So the retention arm is v8-ignored (exercised by review
  1418. // reasoning over the `stray.utf8` state, not by an in-tree test): it
  1419. // withholds the lead-plus-consumed-continuations tail (≤3 bytes, via
  1420. // `stray.utf8.width - stray.utf8.expected`) from the decode, re-carries it
  1421. // for a later chunk, and re-accrues the pipe's cost/UTF-8 state over it;
  1422. // decoding here would render a LEGAL, unfinished character as U+FFFD in an
  1423. // admitted entry. Every retainPartialTail=false call (the `end`/closeDeadline
  1424. // paths) and a budget flush with no partial tail in flight (`expected === 0`)
  1425. // falls through with `keep` unset: the FULL residual is decoded — there a
  1426. // trailing incomplete sequence is real truncated input and the U+FFFD is the
  1427. // honest render.
  1428. let keep: Buffer | undefined
  1429. /* v8 ignore next 18 -- mid-sequence budget-flush boundary is not schedulable from a test. */
  1430. if (retainPartialTail && stray.utf8.expected > 0) {
  1431. const drop = Math.min(stray.utf8.width - stray.utf8.expected, full.length)
  1432. keep = full.subarray(full.length - drop)
  1433. full = full.subarray(0, full.length - drop)
  1434. stray.chunks = detachResidual(keep)
  1435. // Re-accrue the withheld tail from a FRESH state: `stray.utf8` still
  1436. // holds the whole-pending state (`expected > 0`, i.e. the tail is
  1437. // mid-sequence), so metering `keep` against it would charge the carried
  1438. // LEAD byte as an illegal continuation. Reset, then walk `keep` so the
  1439. // resumed sequence re-claims its own lead.
  1440. stray.utf8 = { expected: 0, width: 0, lowerFirst: 0, upperFirst: 0 }
  1441. stray.cost = accrueStrayCost(keep, stray.utf8)
  1442. stray.blocks = []
  1443. // Do not admit an EMPTY entry: when the whole residual is a single
  1444. // unfinished multibyte sequence, `full` was drained into `keep` and no
  1445. // complete byte stream remains to admit. `admit('')` would push a
  1446. // model-visible bogus empty line (logs are joined with '\n' downstream).
  1447. if (full.length > 0) admit(full.toString('utf8'))
  1448. } else {
  1449. stray.chunks = []
  1450. stray.cost = 0
  1451. stray.utf8 = { expected: 0, width: 0, lowerFirst: 0, upperFirst: 0 }
  1452. stray.blocks = []
  1453. admit(full.toString('utf8'))
  1454. }
  1455. }
  1456. child.stdout.on('data', (chunk: Buffer) => { captureStray(strayOut, chunk) })
  1457. child.stderr.on('data', (chunk: Buffer) => { captureStray(strayErr, chunk) })
  1458. child.stdout.on('end', () => { flushStray(strayOut) })
  1459. child.stderr.on('end', () => { flushStray(strayErr) })
  1460. // Line-framed JSON reader over fd 3. The unframed buffer is bounded: a
  1461. // hostile program can loop `os.write(3, b"A"*4096)` with no newline to
  1462. // exhaust HOST memory, which the child's RLIMIT_AS does not cover. It is
  1463. // a memory-safety bound only: legitimate `call` frames may be large
  1464. // (binding traffic has no seam byte cap), so it never keys off
  1465. // maxValueBytes.
  1466. // Buffered as raw chunks with a running byte counter: appending is O(1)
  1467. // per chunk (a string `+=` accumulator would re-copy the whole prefix on
  1468. // every pipe chunk — quadratic on a large frame), joins happen only when
  1469. // a newline actually arrived, and the ceiling check reads the counter.
  1470. let pendingChunks: Buffer[] = []
  1471. // Fragments already merged into finished blocks. Kept separate from
  1472. // `pendingChunks` so sealing never re-copies what earlier seals produced;
  1473. // the two together are the unframed buffer, and `pendingBytes` counts both.
  1474. let sealedBlocks: Buffer[] = []
  1475. let pendingBytes = 0
  1476. proto.on('data', (chunk: Buffer) => {
  1477. // Once settled, stop accumulating: a hostile child that keeps flooding
  1478. // fd 3 between finish() and close must not regrow the host buffer.
  1479. /* v8 ignore next -- post-settlement data needs the child to outrace close after we decided. */
  1480. if (settled) return
  1481. // Schedule ONE post-batch outstanding-call check per macrotask. The
  1482. // check must see the TRUE count — the live count is inflated by this
  1483. // batch's own frames (the finallys run on the microtask queue, which
  1484. // drains only when the macrotask ends), and a per-event snapshot is
  1485. // stale when flowing mode fires several 'data' events within one
  1486. // macrotask before any microtask drains. setImmediate runs after the
  1487. // current macrotask's microtasks, so the count is exact; the flag
  1488. // dedupes the check across the events of one macrotask. The threshold
  1489. // is STRICT: exactly MAX_PENDING_REPLIES outstanding calls are allowed,
  1490. // so a program that returns with calls it never awaited still
  1491. // completes (the done frame settles the run; the check no-ops on
  1492. // `settled`).
  1493. if (!postBatchCheckPending) {
  1494. postBatchCheckPending = true
  1495. setImmediate(() => {
  1496. postBatchCheckPending = false
  1497. /* v8 ignore next -- the done frame can settle the run between the schedule and this callback. */
  1498. if (settled) return
  1499. if (pendingCalls > MAX_PENDING_REPLIES) {
  1500. finish({ error: { kind: 'worker-exit', message: `call backlog exceeded ${MAX_PENDING_REPLIES} in-flight binding calls (a binding never settled)` } })
  1501. }
  1502. })
  1503. }
  1504. pendingChunks.push(chunk)
  1505. pendingBytes += chunk.length
  1506. // Check the counter BEFORE the join, not the joined line afterwards:
  1507. // Buffer.concat allocates a second copy of everything held, so a line
  1508. // measured after the concat had already cost twice the ceiling — the
  1509. // ceiling this check exists to enforce. The counter is exact and free,
  1510. // and the retained chunks are released here so the rejected payload is
  1511. // not still held while the run settles.
  1512. //
  1513. // The counter charges the whole unframed buffer, which over-counts by at
  1514. // most the newline-bearing chunk's own length (one pipe read): the
  1515. // residual carried in is always a partial line, so nothing but the
  1516. // current line can be larger than that. That over-count is deliberate and
  1517. // load-bounded on the OTHER side: the config cap is `parse-cap - envelope`,
  1518. // and a legitimate near-cap frame plus a following chunk's leading bytes
  1519. // could in principle nudge the counter over the cap for one read window
  1520. // — but only when maxLogBytes/maxValueBytes is configured within one
  1521. // pipe read of the 64 MiB cap, orders of magnitude past the 32/64 KiB
  1522. // defaults.
  1523. //
  1524. // The cap is enforced ONLY when the held bytes are still a single
  1525. // unframed line (this chunk carries no newline, and earlier
  1526. // newline-bearing chunks were joined immediately): a frame past the cap
  1527. // would otherwise be fully `Buffer.concat`-ed (a second copy of its
  1528. // bytes) and only then dropped in the line loop — the peak-memory
  1529. // doubling this pre-concat check exists to prevent. Dropping the
  1530. // oversized unframed buffer before the join keeps the peak at one copy
  1531. // of the wire bytes. When this chunk DOES carry a newline the buffer
  1532. // holds several frames, so the FIRST-FRAME check below (not this
  1533. // counter, which charges them all) decides.
  1534. if (pendingBytes > this.frameParseCapBytes && !chunk.includes(0x0a)) {
  1535. pendingChunks = []
  1536. sealedBlocks = []
  1537. pendingBytes = 0
  1538. finish({ error: { kind: 'worker-exit', message: `protocol frame exceeded ${this.frameParseCapBytes} bytes on fd 3` } })
  1539. return
  1540. }
  1541. // Bound the FRAGMENT COUNT as well as the byte total, but only AFTER the
  1542. // ceiling check above: sealing first would `Buffer.concat` an already
  1543. // over-ceiling payload and allocate a second copy of it before the
  1544. // rejection ran, which is the peak-memory doubling that check exists to
  1545. // prevent.
  1546. //
  1547. // Fragment count needs its own bound because the ceiling meters payload
  1548. // bytes only, while each retained chunk is a separate Buffer with object
  1549. // and backing-store overhead no byte count sees: 5000 single-byte
  1550. // newline-free writes produced 5000 chunks holding 5031 bytes, so a
  1551. // program pacing such writes could accumulate millions of objects inside
  1552. // the wall budget and exhaust the host heap far below the ceiling.
  1553. //
  1554. // Sealing appends to a list of finished blocks instead of re-merging
  1555. // everything held. Concatenating the whole buffer at each threshold
  1556. // re-copied the entire accumulated prefix every time, so the cumulative
  1557. // copy volume was quadratic, not the amortized O(1) an earlier revision
  1558. // of this comment claimed: 10 MiB trickled a byte at a time copies
  1559. // 53.7 GB that way, and 64 MiB copies 2.2 TB. Here each byte is copied
  1560. // once into its block and never again, so the total stays linear, and the
  1561. // block list is itself bounded — every block holds at least
  1562. // `MAX_PENDING_CHUNKS - 1` bytes, so reaching the 64 MiB cap admits
  1563. // at most a few hundred thousand of them.
  1564. // Sealing runs ONLY on a newline-free chunk, and after the newline
  1565. // branch below: a chunk carrying a newline must reach the join (and its
  1566. // first-frame check) rather than being sealed into a block the check
  1567. // would then not scan for newlines. That keeps the invariant
  1568. // `sealedBlocks hold newline-free prefixes only` true, so the
  1569. // first-frame scan below can charge each sealed block's whole length
  1570. // toward the first frame without missing a newline inside it.
  1571. if (chunk.includes(0x0a)) {
  1572. // First-FRAME check before the join: measure the bytes up to the
  1573. // first newline across the held chunks. The byte counter cannot
  1574. // serve here — it charges the whole buffer, which legitimately
  1575. // holds several frames each within the cap. A first frame past the
  1576. // cap is dropped before the join (one copy of its wire bytes);
  1577. // later frames in the same buffer are handled line by line in the
  1578. // loop below.
  1579. let firstFrameLen = 0
  1580. let sawNewline = false
  1581. // Sealed blocks hold newline-free prefixes only (see the sealing
  1582. // gate below), so they are entirely part of the first frame.
  1583. for (const b of sealedBlocks) firstFrameLen += b.length
  1584. for (const c of pendingChunks) {
  1585. const nl = c.indexOf(0x0a)
  1586. if (nl >= 0) {
  1587. firstFrameLen += nl
  1588. sawNewline = true
  1589. break
  1590. }
  1591. firstFrameLen += c.length
  1592. }
  1593. if (sawNewline && firstFrameLen > this.frameParseCapBytes) {
  1594. pendingChunks = []
  1595. sealedBlocks = []
  1596. pendingBytes = 0
  1597. finish({ error: { kind: 'worker-exit', message: `protocol frame exceeded ${this.frameParseCapBytes} bytes on fd 3` } })
  1598. return
  1599. }
  1600. let buffered = Buffer.concat(sealedBlocks.length > 0 ? [...sealedBlocks, ...pendingChunks] : pendingChunks)
  1601. sealedBlocks = []
  1602. let newline: number
  1603. while ((newline = buffered.indexOf(0x0a)) >= 0) {
  1604. const line = buffered.subarray(0, newline)
  1605. buffered = buffered.subarray(newline + 1)
  1606. /* v8 ignore next -- an empty line comes only from a forged `\n\n` write. */
  1607. if (line.length === 0) continue
  1608. // No per-line cap check here: the pre-join counter (single unframed
  1609. // line) and the first-frame check (newline-bearing chunk) above
  1610. // reject any frame past FRAME_PARSE_CAP_BYTES before this join, so
  1611. // every line in this loop is within the cap by construction — a
  1612. // per-line check would be dead code.
  1613. // `toString('utf8')` would silently REPLACE illegal bytes with
  1614. // U+FFFD, corrupting a completion or binding payload a forged
  1615. // frame smuggled in (the honest child's lossless encoder never
  1616. // emits non-UTF-8, so such a frame is hostile traffic). The fatal
  1617. // decode throws on them and the frame is dropped — not accepted
  1618. // with a mangled value — the same treatment as the unsafe-integer
  1619. // check below.
  1620. let text: string
  1621. try {
  1622. text = UTF8_FATAL.decode(line)
  1623. } catch {
  1624. continue
  1625. }
  1626. // JSON.parse would silently ROUND an integer token outside the
  1627. // safe range before validation could see it, so a forged frame
  1628. // could smuggle a corrupted value into a dispatch or completion.
  1629. // An honest child never emits one (its validator rejects unsafe
  1630. // ints), so such a frame is hostile traffic: drop it like any
  1631. // other junk frame.
  1632. if (hasUnsafeIntegerToken(text)) continue
  1633. let parsed: unknown
  1634. try {
  1635. parsed = JSON.parse(text) as unknown
  1636. } catch {
  1637. continue // Junk frames drop silently (hostile-peer stance).
  1638. }
  1639. const message = validateChildFrame(parsed)
  1640. if (message) handleFrame(message)
  1641. }
  1642. // Carry the residual forward as a fresh, right-sized copy, NOT the
  1643. // `subarray` view: a view keeps the whole joined-frame allocation from
  1644. // the `Buffer.concat` above alive, so a large frame followed by a tiny
  1645. // trailing fragment would pin megabytes while `pendingBytes` reported
  1646. // only the fragment's length. See {@link detachResidual}.
  1647. pendingChunks = detachResidual(buffered)
  1648. pendingBytes = buffered.length
  1649. } else if (pendingChunks.length >= MAX_PENDING_CHUNKS) {
  1650. // A newline-free run past the fragment-count bound: seal the held
  1651. // chunks into one finished block (amortized O(1) per byte, see the
  1652. // comment above the count bound) and keep accumulating. The gate on
  1653. // `chunk.includes(0x0a)` is the ELSE half of the newline branch, so a
  1654. // newline-bearing chunk never lands in a sealed block.
  1655. sealedBlocks.push(Buffer.concat(pendingChunks))
  1656. pendingChunks = []
  1657. }
  1658. })
  1659. // Duplicate-call suppression against the honest child's id SEQUENCE, not
  1660. // a set of every id seen. `dispatch` sends consecutive ids from 0 with no
  1661. // gaps — it advances its counter only after the write succeeds, so a call
  1662. // rejected before reaching the wire consumes nothing — which makes the
  1663. // next legitimate id exactly `nextCallId`.
  1664. //
  1665. // Retaining a set instead let a program write an unbounded run of unique
  1666. // forged ids, each below the 64 MiB per-frame parse cap so nothing
  1667. // rejected them, and grow host memory for the whole run. Accepting any
  1668. // id above a high-water mark would have been just as wrong in the other
  1669. // direction: one forged `{"id": 9999}` would starve every honest call
  1670. // after it. The exact successor is the only test that both bounds the
  1671. // retained state to one number and cannot be poisoned by a forgery.
  1672. let nextCallId = 0
  1673. // Set by run() when the boot frame is written; the fd-3 handler calls it
  1674. // on boot-ack to send the run frame (see the seam's boot->boot-ack->run
  1675. // order). scoped per run. An object holder so the cross-closure
  1676. // assignment is a property write (eslint's prefer-const cannot see the
  1677. // reassignment through the closure).
  1678. const bootAckGate: { run?: () => void } = {}
  1679. const handleFrame = (message: ChildToHost): void => {
  1680. /* v8 ignore next -- late frame after settlement; defensive against forged post-settlement traffic. */
  1681. if (settled) return
  1682. switch (message.type) {
  1683. case 'boot-ack':
  1684. // The child accepted the boot frame (namespaces built); the run
  1685. // frame goes out now, not with the boot frame.
  1686. bootAckGate.run?.()
  1687. return
  1688. case 'log':
  1689. if (message.truncated === true) {
  1690. // The CHILD ledger hit its cap. Its marker is the last log text
  1691. // there will be, so record it and stop host capture at the same
  1692. // point: admitting it as ordinary text left the host budget open,
  1693. // so later direct `os.write(1, ...)` bytes were retained AFTER the
  1694. // marker and a host-side exhaustion could append a second one.
  1695. // Both ledgers are keyed to the same `maxLogBytes`, so one marker
  1696. // describes the run.
  1697. if (!logsTruncated) {
  1698. // The host's OWN marker, never the frame's text. `truncated` is
  1699. // attacker-reachable, so trusting the text let a program write
  1700. // `{"type":"log","truncated":true,"text":<1 MiB>}` and land all
  1701. // of it in `logs` under a 64-byte `maxLogBytes` — measured, the
  1702. // whole megabyte was retained, bypassing `admit` and its
  1703. // ceiling. Both ledgers key off the same `maxLogBytes`, so the
  1704. // marker the host generates says the same thing the child's
  1705. // would have.
  1706. truncateLogs()
  1707. }
  1708. return
  1709. }
  1710. if (message.open === true) {
  1711. // An explicit flush of an unterminated line: hold it so the next
  1712. // frame appends to the SAME entry (print('a', end='', flush=True)
  1713. // followed by print('b') reads back as one 'ab' entry, not a fake
  1714. // newline). Billed INCREMENTALLY so k tiny frames cost O(k), not
  1715. // O(k * budget) (re-walking the whole held text per frame): the
  1716. // first fragment is charged the full JSON-string cost plus the
  1717. // separator (quotes + content + newline), each continuation only
  1718. // its content (jsonStringCostUpTo includes the two quotes), and
  1719. // the closing frame only its own content — the merged entry's
  1720. // wire cost is billed exactly once, split across the fragments.
  1721. // Caps: the first fragment's exact-cost walk uses logBudget - 1
  1722. // (the ledger's reserved byte, matching admit), a continuation's
  1723. // logBudget + 2 (a continuation is billed WITHOUT quotes, so its
  1724. // billed cost cost - 2 fits exactly when the walk's cost is at
  1725. // most logBudget + 2).
  1726. if (!logsTruncated) {
  1727. // An EMPTY first open frame (openParts empty AND text '') bills
  1728. // cost + 1 = 3 but establishes no hold (the push is skipped),
  1729. // so the next frame is billed as a new first fragment. Not
  1730. // reachable from an honest child (_LogStream.write('') returns
  1731. // early; flush_line pushes only non-empty pending); for a
  1732. // forged frame it is a bounded over-charge in the safe
  1733. // direction (a flood exhausts the ledger into truncation).
  1734. const cap = openParts.length === 0 ? logBudget - 1 : logBudget + 2
  1735. const cost = jsonStringCostUpTo(message.text, cap)
  1736. if (cost === undefined) {
  1737. truncateLogs()
  1738. } else {
  1739. const bill = openParts.length === 0 ? cost + 1 : Math.max(cost - 2, 0)
  1740. logBudget -= bill
  1741. // A zero-content continuation (text '') bills 0; holding it
  1742. // would grow the fragment array without touching the ledger,
  1743. // so a forged empty-open flood could grow host memory — skip
  1744. // the push, the merge result is unchanged.
  1745. if (message.text !== '') {
  1746. if (openParts.length >= MAX_PENDING_CHUNKS) {
  1747. openSealed.push(openParts.join(''))
  1748. openParts = []
  1749. }
  1750. openParts.push(message.text)
  1751. }
  1752. }
  1753. }
  1754. return
  1755. }
  1756. if (openParts.length > 0) {
  1757. // Closing frame: the held fragments are already billed; bill only
  1758. // this frame's own content (the quotes and separator ride on the
  1759. // first fragment) and push the merged entry once. Cap is
  1760. // logBudget + 2 for the same reason as a continuation.
  1761. /* v8 ignore next -- logsTruncated is an invariant false here: an open
  1762. * frame that would trip the ledger resets openParts, so a non-empty
  1763. * hold implies the ledger never truncated. The guard is defensive. */
  1764. if (!logsTruncated) {
  1765. const cost = jsonStringCostUpTo(message.text, logBudget + 2)
  1766. if (cost === undefined) {
  1767. truncateLogs()
  1768. } else {
  1769. logBudget -= Math.max(cost - 2, 0)
  1770. logs.push(openSealed.join('') + openParts.join('') + message.text)
  1771. }
  1772. }
  1773. openSealed = []
  1774. openParts = []
  1775. return
  1776. }
  1777. admit(message.text)
  1778. return
  1779. case 'done': {
  1780. // The call-backlog cap must also hold when the child finishes in
  1781. // the SAME batch as its flood: the post-macrotask check no-ops once
  1782. // this done frame settles the run, so a done arriving right after
  1783. // more than MAX_PENDING_REPLIES call frames in one data event would
  1784. // otherwise complete successfully with the outstanding closures
  1785. // left behind (a single sub-64 KiB write can carry 1025 compact
  1786. // calls plus a done). The strict threshold lets exactly
  1787. // MAX_PENDING_REPLIES outstanding calls — a program that returned
  1788. // without awaiting its calls — complete normally.
  1789. if (pendingCalls > MAX_PENDING_REPLIES) {
  1790. finish({ error: { kind: 'worker-exit', message: `call backlog exceeded ${MAX_PENDING_REPLIES} in-flight binding calls (a binding never settled)` } })
  1791. return
  1792. }
  1793. if (message.error) {
  1794. finish({ error: { kind: message.error.kind, message: capMessage(message.error.message, this.config.maxValueBytes) } })
  1795. return
  1796. }
  1797. if (message.value === undefined) {
  1798. finish({})
  1799. return
  1800. }
  1801. // Re-enforce the completion budget and number losslessness
  1802. // host-side: a forged done frame bypasses the Python-side
  1803. // _done_with_value check, and validateChildFrame no longer scans
  1804. // the value (an unbounded scan would push every member of a wide
  1805. // forgery before any cap ran). checkDoneValue folds both jobs into
  1806. // one bounded, iterative traversal — iterative because the seam's
  1807. // PtcJsonValue has no depth limit and an honest deep-but-small
  1808. // completion must cross intact rather than dying on stringify
  1809. // recursion; bounded because it stops at the cap without
  1810. // materializing the encoding, rejecting a forged value anywhere
  1811. // below the 64 MiB frame parse cap before it forces host-side copies.
  1812. // The seam forbids substituting a rendered/truncated value, so an
  1813. // oversized value fails the run as output-limit and a non-lossless
  1814. // number as invalid-output. The value is JSON-plain by construction
  1815. // (it came from JSON.parse of the frame), the traversal's precondition.
  1816. const check = checkDoneValue(message.value, this.config.maxValueBytes)
  1817. if (!check.ok) {
  1818. finish(check.reason === 'over-budget'
  1819. ? { error: { kind: 'output-limit', message: `completion value exceeded ${this.config.maxValueBytes} bytes` } }
  1820. : { error: { kind: 'invalid-output', message: 'completion value contained a non-lossless number' } })
  1821. return
  1822. }
  1823. finish({ value: message.value as PtcJsonValue })
  1824. return
  1825. }
  1826. case 'call': {
  1827. if (message.id !== nextCallId) return
  1828. nextCallId += 1
  1829. const record = bindings.get(message.global)?.functions
  1830. const fn = record && Object.hasOwn(record, message.name) ? record[message.name] : undefined
  1831. if (typeof fn !== 'function') {
  1832. // `call.global` and `call.name` are attacker-controlled strings
  1833. // with no byte cap of their own — only the 64 MiB fd-3 frame
  1834. // parse cap — so each is sliced to `maxValueBytes` CODE UNITS
  1835. // BEFORE it reaches the template. Interpolating them whole would
  1836. // copy them into the message, `JSON.stringify` would copy the
  1837. // escaped form, `encodeJsonPlain` the frame, and the pipe write
  1838. // again: four full-size host allocations off one below-ceiling
  1839. // forgery, past every hostile-peer bound the log and done-error
  1840. // paths apply. Nothing past the first `maxValueBytes` code units
  1841. // of either field can survive the byte cap anyway, so the slices
  1842. // lose only text `capMessage` would drop, and that final cap
  1843. // gives this reply the same budget and marker as a forged done
  1844. // error.
  1845. const cap = this.config.maxValueBytes
  1846. const target = `${message.global.slice(0, cap)}.${message.name.slice(0, cap)}`
  1847. // JSON.stringify on the WHOLE capped target would still allocate
  1848. // the escaped form — up to ~6x under control-heavy input, a
  1849. // multi-hundred-MB spike near the maxValueBytes ceiling that no
  1850. // hostile-peer bound would have admitted. The message only needs
  1851. // to identify the binding, so the escaped form is built from a
  1852. // 1 KiB prefix; capMessage then enforces the reply budget.
  1853. const preview = JSON.stringify(target.slice(0, 1024))
  1854. sendReply({ type: 'reply', id: message.id, ok: false, message: capMessage(`unknown binding ${preview}`, cap) })
  1855. return
  1856. }
  1857. // Count the outstanding binding call before dispatch and release the
  1858. // slot in the async body's finally. The CAP CHECK runs in the data
  1859. // handler's post-macrotask pass (where the finallys have drained),
  1860. // not here: a per-frame check would see every frame of one event as
  1861. // in-flight and false-positive on a legitimate gather of more than
  1862. // MAX_PENDING_REPLIES instant calls.
  1863. pendingCalls += 1
  1864. void (async () => {
  1865. try {
  1866. const resolved = await fn(message.args)
  1867. // Drop a reply the run no longer needs BEFORE snapshotting it.
  1868. // `sendReply` also checks `settled`, but only after this value has
  1869. // been walked and copied: a binding that resolves a wide value
  1870. // after `maxWallMs`, an abort, or dispose already settled the run
  1871. // would spend host heap on a frame that is then discarded, and
  1872. // binding resolution carries no seam-level byte cap to bound it.
  1873. // oxlint-disable-next-line typescript/no-unnecessary-condition -- the run can settle while this binding is awaited.
  1874. if (settled) return
  1875. // The seam requires a lossy resolution to REJECT descriptively,
  1876. // not silently coerce: a raw JSON.stringify would turn NaN/
  1877. // Infinity into null and drop undefined fields. Snapshot through
  1878. // the same lossless-JSON boundary the Node backend uses (also
  1879. // iterative, so a deeply nested value cannot overflow the stack).
  1880. const value = snapshotJsonValue(resolved)
  1881. if (value === undefined) {
  1882. sendReply({ type: 'reply', id: message.id, ok: false, message: 'binding resolution must be lossless JSON' })
  1883. return
  1884. }
  1885. sendReply({ type: 'reply', id: message.id, ok: true, value })
  1886. } catch (error: unknown) {
  1887. // Check `settled` before formatting the error: a rejection that
  1888. // arrives after `maxWallMs`, an abort, or dispose has already
  1889. // settled the run, and `messageOf(error)` runs hostile getters
  1890. // before `sendReply` peeks at `settled`. Dropping the framed
  1891. // reply early spares the host heap and time for a run whose
  1892. // outcome is already fixed.
  1893. // (oxlint block-disable so both `v8 ignore next` and the rule
  1894. // suppression land on the `if`: `settled` flips true mid-wait,
  1895. // invisible to the type-aware lint, which narrows it to false.)
  1896. /* oxlint-disable typescript/no-unnecessary-condition */
  1897. /* v8 ignore next -- a rejection arriving after settlement is not schedulable from a test. */
  1898. if (settled) return
  1899. /* oxlint-enable typescript/no-unnecessary-condition */
  1900. sendReply({ type: 'reply', id: message.id, ok: false, message: messageOf(error) })
  1901. } finally {
  1902. // Release the in-flight slot on every exit — reply written,
  1903. // resolution rejected, or the run settling mid-wait (the
  1904. // `settled` early returns above). Without this, a binding that
  1905. // never resolves would leak its slot past the cap check and the
  1906. // flood bound would erode.
  1907. pendingCalls -= 1
  1908. }
  1909. })()
  1910. return
  1911. }
  1912. }
  1913. }
  1914. // Write one reply frame with the iterative encoder: a binding
  1915. // resolution has no seam-level depth or byte cap, so a deeply nested
  1916. // value must not die on JSON.stringify's recursion. The payload is
  1917. // JSON-plain by construction (snapshotJsonValue output, or literal
  1918. // strings/numbers), which is encodeJsonPlain's precondition. A closed
  1919. // pipe (child already gone) is swallowed since the close path settles
  1920. // the run.
  1921. //
  1922. // Replies are encoded and written ONE AT A TIME, waiting for `drain`
  1923. // whenever fd 3's buffer is full. Binding resolution carries no
  1924. // seam-level byte cap, so a program that resolves several large values in
  1925. // one `asyncio.gather` round would otherwise encode them all in the same
  1926. // turn and queue every frame in the writable stream's buffer -- measured
  1927. // to exhaust a 256 MiB Node heap, which kills the whole host process
  1928. // rather than failing this one run. Pacing changes no model-visible
  1929. // behavior: the child matches each reply to its `call` by id from a pump
  1930. // that reads fd 3 continuously, so arrival order was never observable,
  1931. // and the bindings themselves still run concurrently. Only the host's peak
  1932. // memory and the flush timing change.
  1933. const replyQueue: ReplyMessage[] = []
  1934. // Replies queued but not yet written, tracked separately from
  1935. // `replyQueue.length`: the drain loop clears consumed slots to `undefined`
  1936. // but does not shrink the array until it finishes, so `length` counts
  1937. // consumed frames too. The counter is what the cap in `sendReply` reads.
  1938. let pendingReplies = 0
  1939. // Binding calls dispatched but not yet settled (the async body below
  1940. // still awaits the binding's promise). The reply backlog cap only counts
  1941. // RESOLVED calls — `pendingReplies` grows after the await — so a child
  1942. // flooding calls against a binding that never settles would accumulate
  1943. // one async closure per frame until the wall clock without tripping it.
  1944. // Counted here before dispatch and released in the body's finally; the
  1945. // data handler schedules a post-macrotask check (see there) that settles
  1946. // the run as worker-exit when the true outstanding count passes
  1947. // MAX_PENDING_REPLIES.
  1948. let pendingCalls = 0
  1949. // Dedupes the post-batch outstanding-call check across the 'data' events
  1950. // of one macrotask (see the data handler).
  1951. let postBatchCheckPending = false
  1952. let draining = false
  1953. // Resolve when fd 3 can take another frame, OR when it is gone: a pipe
  1954. // destroyed under the drain (child exited, close-deadline teardown) never
  1955. // emits 'drain' again, so waiting on that event alone would hang the
  1956. // drain forever — `draining` stays true and the unconsumed queue is
  1957. // pinned with the closure. `once` plus the manual detach removes every
  1958. // listener whichever event wins, so a long backpressure wait leaves none
  1959. // behind.
  1960. const waitForDrain = (): Promise<void> => new Promise<void>((resolvePromise) => {
  1961. const finish = (): void => {
  1962. proto.off('drain', finish)
  1963. proto.off('close', finish)
  1964. proto.off('error', finish)
  1965. resolvePromise()
  1966. }
  1967. proto.once('drain', finish)
  1968. proto.once('close', finish)
  1969. proto.once('error', finish)
  1970. })
  1971. const drainReplies = async (): Promise<void> => {
  1972. if (draining) return
  1973. draining = true
  1974. let head = 0
  1975. try {
  1976. while (head < replyQueue.length) {
  1977. // Needs the run to settle between two queued frames. Measured queue
  1978. // depths reach 11 without the wall clock landing inside that window.
  1979. /* v8 ignore next -- see above; not schedulable from a test. */
  1980. if (settled) break
  1981. // A pipe destroyed under us (child exited, close deadline) will
  1982. // never emit 'drain' again; short-circuit before the write so the
  1983. // remaining frames are dropped by the `finally` below.
  1984. if (proto.destroyed) break
  1985. // Read by index, not `shift()`: a large `asyncio.gather` of wide
  1986. // bindings awaiting fd 3's `drain` can queue many frames, and each
  1987. // `shift()` re-slices the remaining array (O(n) per pop, O(n²) over
  1988. // the whole drain). A head cursor keeps the cost linear; the `finally`
  1989. // below discards everything consumed once the drain ends. The consumed
  1990. // slot is CLEARED here (not just advanced past) so a wide payload the
  1991. // pipe has already taken is released immediately: under sustained
  1992. // backpressure the drain loop can live across many `await drain`
  1993. // ticks, and leaving the slot set would pin the written value's bytes
  1994. // in `replyQueue` for the whole busy period, making host memory grow
  1995. // with cumulative processing rather than the current backlog.
  1996. const payload = replyQueue[head] as ReplyMessage
  1997. replyQueue[head] = undefined as unknown as ReplyMessage
  1998. head += 1
  1999. pendingReplies -= 1
  2000. // Compact the consumed prefix once it reaches the backlog bound:
  2001. // the array never shrinks until the drain finishes, and a child
  2002. // that reads replies just fast enough to keep the drain alive but
  2003. // never empty would otherwise grow the backing store linearly with
  2004. // cumulative throughput (consumed slots are undefined, but `length`
  2005. // keeps counting them). The splice is O(head) once per
  2006. // MAX_PENDING_REPLIES consumed frames — amortized O(1) per reply.
  2007. if (head >= MAX_PENDING_REPLIES) {
  2008. replyQueue.splice(0, head)
  2009. head = 0
  2010. }
  2011. // Encode inside the loop, not up front: a queued reply the run no
  2012. // longer needs is dropped by the `settled` check above without ever
  2013. // being serialized.
  2014. if (!proto.write(`${encodeJsonPlain(payload)}\n`)) {
  2015. await waitForDrain()
  2016. }
  2017. }
  2018. } catch {
  2019. // Pipe closed under us (child exited), or `drain` never arrives because
  2020. // the child died. The close path settles the run either way.
  2021. } finally {
  2022. draining = false
  2023. pendingReplies = 0
  2024. replyQueue.length = 0
  2025. }
  2026. }
  2027. const sendReply = (payload: ReplyMessage): void => {
  2028. /* v8 ignore next -- `settled` covers a race where the child exits between decision and write. */
  2029. if (settled) return
  2030. // A child that stops reading fd 3 leaves the drain loop blocked on
  2031. // `drain` forever while its call frames keep resolving into replies:
  2032. // the backlog would grow without bound until the wall clock, pinning
  2033. // every binding result the child provokes. Cap the retained backlog and
  2034. // settle the run as a worker-exit, the same hostile-peer bound the
  2035. // frame cap applies to inbound bytes.
  2036. if (pendingReplies >= MAX_PENDING_REPLIES) {
  2037. finish({ error: { kind: 'worker-exit', message: `reply queue exceeded ${MAX_PENDING_REPLIES} pending frames on fd 3 (the child stopped consuming its replies)` } })
  2038. return
  2039. }
  2040. pendingReplies += 1
  2041. replyQueue.push(payload)
  2042. void drainReplies()
  2043. }
  2044. // Escalate SIGTERM → grace → SIGKILL on the entire process group. Idempotent
  2045. // via `killing`.
  2046. let killing = false
  2047. let graceTimer: NodeJS.Timeout | undefined
  2048. // A backstop for the one case `close` cannot cover: model code that starts
  2049. // a descendant with `os.setsid()`/`start_new_session=True` moves it into a
  2050. // fresh process group, so the SIGTERM/SIGKILL aimed at the child's group
  2051. // (`kill(-pid)`) never reaches it. If that orphan inherited stdout/stderr/
  2052. // fd 3 and outlives the run, those pipes stay open and `close` never fires
  2053. // — leaving run() (and a teardown awaiting `finished`) hung indefinitely.
  2054. // finish() arms this deadline; when it fires we detach our stream handles
  2055. // and settle on the already-decided result regardless of the orphan.
  2056. let closeDeadline: NodeJS.Timeout | undefined
  2057. // The leader's start time, read once while it is certainly alive. `child.pid`
  2058. // keeps its numeric value after the leader is reaped (Node clears the
  2059. // internal handle, not the field), and `close` can trail `exit` by seconds
  2060. // while a pipe-holding descendant keeps the streams open. Signalling
  2061. // `-child.pid` in that window is a RAW syscall -- `child.kill()` would
  2062. // refuse, having dropped its handle, but `process.kill` has no such guard --
  2063. // so a recycled pgid would receive this run's SIGTERM and armed SIGKILL.
  2064. // `groupEmpty()` cannot cover it: it reports whether the group has members,
  2065. // not whether they are OURS, and it runs only after the first signal.
  2066. // The repository already takes this position in
  2067. // packages/subprocess/subprocess-local (`ProcessIdentity`, "preventing
  2068. // teardown escalation after PID reuse"); this is the same guard, kept local
  2069. // because a dependency on that package would be a new architectural edge.
  2070. const leaderStarted = child.pid === undefined ? undefined : readProcessStart(child.pid)
  2071. const killGroup = (sig: NodeJS.Signals): void => {
  2072. try {
  2073. /* v8 ignore next -- undefined pid means spawn never produced a process; finish() short-circuits before reaching kill(). */
  2074. if (child.pid === undefined) return
  2075. // A pid alone cannot answer this: `process.kill(pid, 0)` succeeds just
  2076. // as well for a REPLACEMENT process holding the recycled number. Only
  2077. // the start time distinguishes the two, so a reading that DISAGREES
  2078. // means the number now belongs to another process and must not be
  2079. // signalled.
  2080. //
  2081. // An ABSENT reading is the ordinary case, not a mismatch: once the
  2082. // leader is reaped its `/proc/<pid>/stat` is gone, while the group it
  2083. // led can still hold survivors that this teardown exists to reap. So
  2084. // only a present-and-different reading blocks the signal; undefined
  2085. // falls through, which is also the behavior on platforms with no
  2086. // `/proc` to read.
  2087. const nowStarted = readProcessStart(child.pid)
  2088. // The refusal arm needs a real pid recycled into a new group leader
  2089. // between spawn and teardown, which no test can schedule; the reader
  2090. // itself is covered directly by the process-identity test.
  2091. /* v8 ignore next -- unreachable without real pid reuse; see above. */
  2092. if (leaderStarted !== undefined && nowStarted !== undefined && nowStarted !== leaderStarted) return
  2093. process.kill(-child.pid, sig)
  2094. } catch {
  2095. // ESRCH — the process already died. Nothing to do.
  2096. }
  2097. }
  2098. const kill = (): void => {
  2099. /* v8 ignore next -- kill() is idempotent; tests do not double-invoke it. */
  2100. if (killing) return
  2101. killing = true
  2102. killGroup('SIGTERM')
  2103. // Escalate to SIGKILL after the grace window. The timer is `unref`'d so a
  2104. // pending SIGKILL never keeps the host process alive on its own; the
  2105. // guarantee that a same-group survivor is actually reaped before the fiber
  2106. // goes quiescent is enforced by settle() awaiting the group's death (see
  2107. // there), NOT by this timer firing during host lifetime. A setsid-escaped
  2108. // orphan in a FRESH group is the different case `closeDeadline` in finish()
  2109. // covers, since `close` never fires there.
  2110. graceTimer = setTimeout(() => { killGroup('SIGKILL') }, this.config.graceMs)
  2111. graceTimer.unref()
  2112. }
  2113. // True once the group has no members left: a signal-0 probe to the whole
  2114. // group (`kill(-pid, 0)`) throws ESRCH when empty (EPERM would still mean a
  2115. // member exists). Only meaningful once a spawn produced a pid.
  2116. const groupEmpty = (): boolean => {
  2117. /* v8 ignore next -- pid is always defined once escalation runs; the guard narrows the type. */
  2118. if (child.pid === undefined) return true
  2119. try {
  2120. process.kill(-child.pid, 0)
  2121. return false
  2122. } catch (error: unknown) {
  2123. return (error as NodeJS.ErrnoException).code === 'ESRCH'
  2124. }
  2125. }
  2126. let finishResolve!: () => void
  2127. const finished = new Promise<void>((done) => { finishResolve = done })
  2128. let resolved = false
  2129. // The decided terminal result for a live child, recorded by finish() and
  2130. // read by the `close` handler that settles it once the pipes have drained.
  2131. let decided: Omit<PtcRunResult, 'logs'>
  2132. // The single settlement point: resolve run() with the decided result and
  2133. // mark the fiber quiescent. Idempotent — the first call wins, so a later
  2134. // `close` after done/timeout/abort is absorbed as a no-op.
  2135. const settle = (result: Omit<PtcRunResult, 'logs'>): void => {
  2136. if (resolved) return
  2137. resolved = true
  2138. if (closeDeadline !== undefined) clearTimeout(closeDeadline)
  2139. // The child has exited by now (settle runs on `close`, or on a spawn
  2140. // that produced no pid), so its staging directory is no longer read and
  2141. // this run's copy goes away with it. Removed SYNCHRONOUSLY, before
  2142. // `resolve` below: a fire-and-forget removal left the directory on disk
  2143. // when `run()` resolved, so a caller could not observe the "gone by
  2144. // settlement" contract at all. Two files cost nothing to unlink here.
  2145. try {
  2146. rmSync(bootstrapDir, { recursive: true, force: true })
  2147. } catch {
  2148. // Swallows only a failure to remove this run's staging directory —
  2149. // `force` already absorbs a missing one, so what remains is a
  2150. // filesystem-level refusal. The run's own outcome is already decided
  2151. // and must still be delivered; the directory holds no secret, only a
  2152. // copy of two checked-in scripts. teardown deliberately does not
  2153. // sweep staging (its staging is cleared inside each run's settle), so
  2154. // a removal failure here is the one case the "gone by settlement"
  2155. // contract degrades on.
  2156. }
  2157. resolve({ ...result, logs })
  2158. // Mark the fiber quiescent for THIS run: drop it from `live` and resolve
  2159. // `finished` (what teardown awaits). Deferred until the process group is
  2160. // actually empty — dropping from `live` before then would let a
  2161. // `dispose()` that races a just-resolved run() snapshot an empty `live`
  2162. // and return while a same-group survivor is still alive, making teardown's
  2163. // "no SAME-GROUP subprocess outlives the fiber" guarantee false for that
  2164. // window (a setsid escapee is the documented exception — see teardown's
  2165. // JSDoc). Keeping the run in `live` until the group is reaped is exactly
  2166. // what makes a concurrent teardown await it.
  2167. const finalize = (): void => {
  2168. this.live.delete(live)
  2169. finishResolve()
  2170. }
  2171. // `finished` is what teardown awaits to honor "no same-group subprocess
  2172. // outlives the fiber". When no escalation ran (normal completion, no
  2173. // kill) or the group is already empty, cancel the pending SIGKILL and
  2174. // finalize now. Clearing it is what bounds the PID-reuse hazard: an armed
  2175. // `kill(-pid)` left to fire up to graceMs later could hit a RECYCLED pgid
  2176. // once the kernel reused the leader's pid, SIGKILLing an unrelated group.
  2177. // So the timer stays armed only while a real survivor exists — a
  2178. // same-group descendant that ignored SIGTERM but released the pipes,
  2179. // still alive here because its `close` is what got us to settle. In that
  2180. // case withhold finalize and poll the group on REF'd timers (a
  2181. // short-lived host would otherwise exit before the unref'd SIGKILL fired,
  2182. // reparenting the survivor to init), clearing the timer the moment the
  2183. // group empties. The wait is bounded by `graceMs + CLOSE_REAP_MARGIN_MS`
  2184. // in the normal case; if the host event loop was blocked past both timers
  2185. // the deadline branch below sends SIGKILL itself and grants ONE more reap
  2186. // margin, so the outer bound is `graceMs + 2 * CLOSE_REAP_MARGIN_MS`.
  2187. if (!killing || groupEmpty()) {
  2188. if (graceTimer !== undefined) clearTimeout(graceTimer)
  2189. finalize()
  2190. return
  2191. }
  2192. const deadline = Date.now() + this.config.graceMs + CLOSE_REAP_MARGIN_MS
  2193. // Once the deadline forces us to send SIGKILL ourselves, allow one more
  2194. // reap window for the kernel to tear the group down before giving up:
  2195. // SIGKILL is asynchronous, so the group is not gone the instant it is
  2196. // sent. `finalize` only runs on a confirmed-empty group, except at this
  2197. // final hard bound where nothing more can be done.
  2198. let hardDeadline = 0
  2199. const pollGroup = (): void => {
  2200. if (groupEmpty()) {
  2201. // The group is gone; the grace SIGKILL is moot. Cancel it (it may not
  2202. // have fired yet) and finalize. graceTimer is always defined here:
  2203. // pollGroup runs only when `killing` is set, and kill() armed it.
  2204. clearTimeout(graceTimer)
  2205. finalize()
  2206. return
  2207. }
  2208. if (hardDeadline === 0 && Date.now() >= deadline) {
  2209. // Deadline reached with the group still non-empty. This is reachable
  2210. // when the host event loop was blocked past both timers: Node runs
  2211. // this poll before the grace SIGKILL timer, so that SIGKILL may never
  2212. // have fired. Send it HERE (idempotent if the timer already ran) and
  2213. // keep polling for the group to actually empty — finalizing on mere
  2214. // signal delivery would declare quiescence while the group is still
  2215. // dying. Bound the extra wait by one more reap margin.
  2216. killGroup('SIGKILL')
  2217. clearTimeout(graceTimer)
  2218. hardDeadline = Date.now() + CLOSE_REAP_MARGIN_MS
  2219. }
  2220. // Hard bound: the self-sent SIGKILL delivered but `groupEmpty()` still
  2221. // reports the group non-empty for a full extra reap margin. This is
  2222. // reachable, not a kernel quirk: a SIGKILL'd same-group survivor
  2223. // lingers as a ZOMBIE until its parent `wait()`s it, and in a
  2224. // container whose PID 1 does not reap orphans the survivor is
  2225. // reparented to init and never waited, so the signal-0 probe keeps
  2226. // succeeding — the same environment dependence the Agent Note's
  2227. // rejected "assert the reap with process.kill(pid, 0)" alternative
  2228. // documents. The ignore stays because that container cannot be built
  2229. // deterministically across CI platforms, not because the branch is
  2230. // unreachable; finalizing here bounds the wait so such a deployment
  2231. // still goes quiescent within `graceMs + 2 * CLOSE_REAP_MARGIN_MS`.
  2232. /* v8 ignore next 4 -- reachable only in a PID-1-doesn't-reap container (zombie survivor); not deterministically buildable. */
  2233. if (hardDeadline !== 0 && Date.now() >= hardDeadline) {
  2234. finalize()
  2235. return
  2236. }
  2237. setTimeout(pollGroup, GROUP_REAP_POLL_MS)
  2238. }
  2239. pollGroup()
  2240. }
  2241. const finish = (result: Omit<PtcRunResult, 'logs'>): void => {
  2242. if (settled) return
  2243. settled = true
  2244. decided = result
  2245. clearTimeout(wallTimer)
  2246. request.signal?.removeEventListener('abort', onAbort)
  2247. // A spawn failure (ENOENT, EACCES) never produced a pid, so there is no
  2248. // process to kill: settle now. Its `close` still fires later and reaches
  2249. // the idempotent settle() again as a no-op.
  2250. // An unterminated flushed line never got a closing frame; it was
  2251. // billed incrementally, so push it directly (admit would re-bill).
  2252. // logsTruncated implies the hold is already empty (truncateLogs
  2253. // committed and cleared it), so this is reachable only when the run
  2254. // ends with the hold still open and untruncated.
  2255. if (openSealed.length > 0 || openParts.length > 0) {
  2256. logs.push(openSealed.join('') + openParts.join(''))
  2257. }
  2258. openSealed = []
  2259. openParts = []
  2260. if (child.pid === undefined) {
  2261. settle(result)
  2262. return
  2263. }
  2264. // Live child: SIGTERM→grace→SIGKILL, then let `close` (below) settle the
  2265. // run so any `done` frame buffered on fd 3 is handled first and the
  2266. // process is fully reaped before the fiber goes quiescent.
  2267. kill()
  2268. // `close` awaits every stdio stream draining, which a setsid-escaped
  2269. // orphan holding our inherited pipes can prevent forever. Bound that
  2270. // wait: after SIGKILL has had the grace window plus a margin to reap the
  2271. // child itself, force settlement on the decided result. Flush any
  2272. // newline-free stray residual FIRST — a leader that wrote a diagnostic
  2273. // with `os.write(1, ...)` and exited leaves it buffered, and destroying
  2274. // the stream below drops it before an `end`/`close` flush could run, so
  2275. // the diagnostic would be lost from `logs`. Detaching the stream handles
  2276. // then lets `close` land as a no-op if it ever arrives, and stops the
  2277. // orphan's stray output from being accounted against a run that already
  2278. // finished. `unref` so the deadline never keeps the host process alive.
  2279. closeDeadline = setTimeout(() => {
  2280. flushStray(strayOut)
  2281. flushStray(strayErr)
  2282. proto.destroy()
  2283. child.stdout.destroy()
  2284. child.stderr.destroy()
  2285. settle(result)
  2286. }, this.config.graceMs + CLOSE_REAP_MARGIN_MS)
  2287. closeDeadline.unref()
  2288. }
  2289. child.on('error', (error: Error) => {
  2290. finish({ error: { kind: 'worker-exit', message: `python spawn error: ${error.message}` } })
  2291. })
  2292. // `close` (not `exit`) is the settlement trigger: it fires only after the
  2293. // process exits AND every stdio stream — including the fd-3 protocol pipe —
  2294. // has drained, so a `done` frame the child wrote just before exiting is
  2295. // always handled before we settle. macOS can deliver `exit` before that
  2296. // final fd-3 data; keying off `close` makes the ordering irrelevant.
  2297. child.on('close', (code: number | null, signal: NodeJS.Signals | null) => {
  2298. // If done/timeout/abort already decided the result, finish() is a no-op
  2299. // and `decided` holds it — a SIGXCPU that arrives after a decision does
  2300. // not override it. Otherwise the child closed before completing: a
  2301. // SIGXCPU close is the kernel's own CPU meter firing — the RLIMIT_CPU
  2302. // soft limit, or the bootstrap's post-settlement getrusage check
  2303. // re-delivering SIGXCPU when a program trapped the soft limit and
  2304. // returned inside the soft-to-hard gap. That kernel-authoritative
  2305. // signal is the ONLY basis for the timeout classification: wall time
  2306. // is not evidence of CPU burn (a sleeping child SIGKILLed by a cgroup
  2307. // OOM killer, an operator, or itself consumed none), so every other
  2308. // signal or code — including an unsolicited SIGKILL, even the
  2309. // hard-limit one — reports as an opaque worker exit.
  2310. //
  2311. // The message names `cpuSeconds` as the CONFIGURED ceiling, not "the
  2312. // budget that fired": the child clamps RLIMIT_CPU to the stricter of
  2313. // `cpuSeconds` and any inherited soft limit, so under a tighter inherited
  2314. // cap SIGXCPU arrives before `cpuSeconds` — the host cannot see the
  2315. // effective value, so it states the ceiling it set rather than a second
  2316. // count it cannot guarantee.
  2317. finish(signal === 'SIGXCPU'
  2318. ? { error: { kind: 'timeout', message: `CPU time exhausted (limit at most the configured ${this.config.cpuSeconds}s; a stricter inherited RLIMIT_CPU can fire sooner)` } }
  2319. : { error: { kind: 'worker-exit', message: `python exited (code=${String(code)}, signal=${String(signal)}) before completing` } })
  2320. settle(decided)
  2321. })
  2322. // Fd-3 and the stdout/stderr pipes emit `error` on early child death
  2323. // (ECONNRESET/EPIPE); swallow them so they do not become uncaught. The
  2324. // authoritative failure signal is `child.on('close')` above.
  2325. const silenceStreamError = (): void => {}
  2326. proto.on('error', silenceStreamError)
  2327. child.stdout.on('error', silenceStreamError)
  2328. child.stderr.on('error', silenceStreamError)
  2329. /* jscpd:ignore-start -- wall-timer/abort/live-run wiring deliberately parallels ptc-runtime-node; see the constructor note. */
  2330. const wallTimer = setTimeout(() => {
  2331. finish({ error: { kind: 'timeout', message: `wall-clock ceiling reached (${this.config.maxWallMs}ms)` } })
  2332. }, this.config.maxWallMs)
  2333. const onAbort = (): void => {
  2334. finish({ error: { kind: 'abort', message: messageOf(request.signal?.reason) } })
  2335. }
  2336. request.signal?.addEventListener('abort', onAbort, { once: true })
  2337. const live: LiveRun = {
  2338. kill,
  2339. finished,
  2340. settle: (failure: PtcRunFailure) => { finish({ error: failure }) },
  2341. }
  2342. this.live.add(live)
  2343. /* jscpd:ignore-end */
  2344. // Send the boot frame once fd 3 is writable. This runs LAST in run()'s
  2345. // synchronous setup: its failure path calls finish(), which reads
  2346. // wallTimer/onAbort and (through settle) live, so those bindings must
  2347. // already be initialized — issuing the write earlier hit their
  2348. // temporal dead zone and threw a ReferenceError that rejected run()
  2349. // instead of resolving the worker-exit it constructs here.
  2350. const boot: BootMessage = {
  2351. type: 'boot',
  2352. cpuSeconds: this.config.cpuSeconds,
  2353. addressSpaceBytes: this.config.addressSpaceMb * 1024 * 1024,
  2354. maxLogBytes: this.config.maxLogBytes,
  2355. maxValueBytes: this.config.maxValueBytes,
  2356. namespaces: [...bindings].map(([global, namespace]) => ({
  2357. global,
  2358. names: Object.keys(namespace.functions),
  2359. ...namespace.errorClass ? { errorClass: namespace.errorClass } : {},
  2360. })),
  2361. }
  2362. // The run frame is sent only after the child's boot-ack: the seam
  2363. // contract puts `run` after `boot-ack` (the ack confirms the namespaces
  2364. // were accepted), and sending it earlier would let a boot failure race
  2365. // the run frame. The ack handler below writes it.
  2366. let runSent = false
  2367. try {
  2368. proto.write(`${JSON.stringify(boot)}\n`)
  2369. } catch (error: unknown) {
  2370. finish({ error: { kind: 'worker-exit', message: `failed to boot python subprocess: ${messageOf(error)}` } })
  2371. return
  2372. }
  2373. // Register the ack gate with the frame handler before any data arrives.
  2374. bootAckGate.run = (): void => {
  2375. if (runSent) return
  2376. runSent = true
  2377. try {
  2378. proto.write(`${JSON.stringify({ type: 'run', program: request.program })}\n`)
  2379. } catch (error: unknown) {
  2380. /* v8 ignore next -- the child exited between its ack and this write; the run settles as worker-exit. */
  2381. finish({ error: { kind: 'worker-exit', message: `failed to boot python subprocess: ${messageOf(error)}` } })
  2382. }
  2383. }
  2384. })
  2385. }
  2386. }
  2387. export default PythonPtcRuntime