index.ts 77 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402
  1. /**
  2. * CPython subprocess code 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 an empty environment, RLIMIT_CPU
  6. * + RLIMIT_AS, wall-clock timeout, and SIGTERM→grace→SIGKILL on the process group.
  7. *
  8. * The package owns the versionless fd-3 wire protocol between the Node host and
  9. * the CPython subprocess. The protocol's host-side codec and hostile-frame
  10. * validators are re-exported so every consumer of the wire shares one
  11. * vocabulary.
  12. * @module @deepseek-ai/dsh-code-runtime-python
  13. */
  14. import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
  15. import { accessSync, copyFileSync, constants as fsConstants, mkdtempSync, rmSync } from 'node:fs'
  16. import { tmpdir } from 'node:os'
  17. import { delimiter, dirname, isAbsolute, join } from 'node:path'
  18. import { fileURLToPath } from 'node:url'
  19. import type { Duplex } from 'node:stream'
  20. import { Context } from 'cordis'
  21. import z from 'schemastery'
  22. import { CodeRuntime, DUNDER_MEMBER, PORTABLE_RESERVED_WORDS, RESERVED_BINDING_GLOBALS, RESERVED_ERROR_MEMBERS } from '@deepseek-ai/dsh-code-runtime'
  23. import type { CodeBindingErrorClass, CodeBindingFunction, CodeJsonValue, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
  24. import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
  25. import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
  26. import type { BootMessage, ChildToHost, ReplyMessage } from './protocol.ts'
  27. import { checkDoneValue, encodeJsonPlain, hasUnsafeIntegerToken, logTruncationMarker, validateChildFrame } from './protocol.ts'
  28. // Re-export the fd-3 wire vocabulary so the runtime and its tests share one
  29. // import surface; the protocol layer owns the definitions.
  30. export type { BootMessage, ChildToHost, ReplyMessage } from './protocol.ts'
  31. export {
  32. checkDoneValue,
  33. encodeJsonPlain,
  34. hasNonLosslessNumber,
  35. hasUnsafeIntegerToken,
  36. logTruncationMarker,
  37. validateChildFrame,
  38. } from './protocol.ts'
  39. /** Plugin config: every cap, changeable from `cordis.yml` (no hardcoded tunables). */
  40. export interface Config {
  41. /**
  42. * RLIMIT_CPU in whole seconds (a positive integer — `setrlimit` in the child
  43. * rejects a float). The child sets the soft limit to `cpuSeconds` and the
  44. * hard limit to `cpuSeconds + 1`: the kernel delivers SIGXCPU at the soft
  45. * limit, which the host classifies as a `timeout`; the +1s hard limit is a
  46. * SIGKILL backstop for a program that traps SIGXCPU. Granularity is seconds —
  47. * a coarser counterpart to the worker backend's millisecond `computeMs`.
  48. */
  49. cpuSeconds?: number
  50. /** Wall-clock ceiling in milliseconds; backstops CPU time for programs awaiting a promise nobody resolves. */
  51. maxWallMs?: number
  52. /**
  53. * RLIMIT_AS in mebibytes; caps address space so a runaway allocation fails
  54. * cleanly. Not applied on Darwin, where the dyld shared cache mapped into
  55. * every process at exec exceeds any practical cap and the kernel rejects
  56. * the call; `cpuSeconds` and `maxWallMs` still bound the run there.
  57. */
  58. addressSpaceMb?: number
  59. /** Shared byte budget for captured log text (host-side ledger). */
  60. maxLogBytes?: number
  61. /** Byte cap for the completion value. */
  62. maxValueBytes?: number
  63. /** SIGTERM→SIGKILL grace period on kill, matching bash-local's default. */
  64. graceMs?: number
  65. /**
  66. * Absolute path or basename of the CPython interpreter to spawn. Resolved
  67. * through `PATH` when a basename is given.
  68. */
  69. pythonBin?: string
  70. }
  71. /** {@link Config} with all defaults filled. */
  72. type ResolvedConfig = Required<Config>
  73. /**
  74. * The seam's language-portable identifier subset (see
  75. * `CodeBindingNamespace.global`) — identical to Python's identifier grammar,
  76. * so the shared contract needs no per-backend mapping here.
  77. */
  78. const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
  79. /**
  80. * The seam's cross-language reserved-word union: the portable-identifier
  81. * contract promises a namespace list valid here is valid on every backend, so
  82. * a JS keyword like `typeof` is refused even though it is a legal Python name.
  83. */
  84. const RESERVED_NAMES = PORTABLE_RESERVED_WORDS
  85. /**
  86. * The seam's shared backend-owned globals (`console` is the worker's slot;
  87. * `__dsh_main__`/`__builtins__`/`__name__` are this bootstrap's wrapper and
  88. * seeded module globals). Shared so a namespace list valid on one backend is
  89. * valid on all — colliding with an owned slot would be silently overwritten
  90. * (or overwrite builtins), so the seam rejects them up front.
  91. */
  92. const RUNTIME_OWNED_GLOBALS = RESERVED_BINDING_GLOBALS
  93. /**
  94. * The seam's shared error-member exclusions (`RESERVED_ERROR_MEMBERS` +
  95. * dunder-form names) — enforced identically here and in the worker backend so
  96. * an errorClass valid on one backend is valid on all. Several dunders are
  97. * constrained CPython descriptors whose `setattr` raises while constructing
  98. * the very rejection it was meant to carry; the exact set is an interpreter
  99. * version detail, hence the dunder-wide rule at the seam.
  100. */
  101. const EXCEPTION_RESERVED_MEMBERS = RESERVED_ERROR_MEMBERS
  102. const DUNDER = DUNDER_MEMBER
  103. /**
  104. * The `py/` scripts the interpreter must be able to open: the entry script plus
  105. * every module it imports from its own directory. Kept beside the built JS so a
  106. * consumer package with `files: ['lib', 'py']` ships both.
  107. */
  108. const PY_SCRIPTS = ['bootstrap.py', 'protocol.py']
  109. /**
  110. * Copy the `py/` scripts to a real filesystem directory and return the entry
  111. * script's path there.
  112. *
  113. * The interpreter is an EXTERNAL process, so it can only open paths the OS
  114. * resolves. Inside the single-file Python-SDK executable, `import.meta.url`
  115. * resolves into pkg's virtual filesystem, which Node reads through its patched
  116. * `fs` but `python3` cannot see at all — the spawn fails with ENOENT on a path
  117. * that exists as far as the host is concerned. `bootstrap.py` additionally
  118. * inserts its own directory on `sys.path` to import the sibling `protocol.py`,
  119. * so both files must land in the SAME real directory.
  120. *
  121. * The copy is unconditional rather than gated on a bundled-runtime probe: the
  122. * read goes through Node's `fs` either way, and one code path means the
  123. * packaged deployment runs what the tests exercise. Placement is under
  124. * `os.tmpdir()` with `0o700` keeps the scripts off other users' reach, but NOT
  125. * the model's: the child runs as the same UID as the host, so a program can
  126. * rewrite the very files it was started from. Hence one copy per RUN, discarded
  127. * at settlement — a rewrite then damages only the run that performed it, which
  128. * is what fresh-subprocess-per-run already promises. Sharing one copy across
  129. * runs made an overwritten `bootstrap.py` break the next run.
  130. *
  131. * Deliberately SYNCHRONOUS. An `await` here would open an async boundary in
  132. * `execute` before the run is registered in `live` and before the abort
  133. * listener is installed, so a disposal or an abort landing in that window would
  134. * be missed: `teardown` would see no runs and return while the continuation
  135. * went on to spawn a subprocess, and an `addEventListener('abort')` installed
  136. * afterwards does not replay an event that already fired. Three small
  137. * filesystem operations per run are not worth that class of race, and `execute`
  138. * already runs synchronously up to `spawn`.
  139. *
  140. * A failed copy removes the directory here, so a partial attempt never outlives
  141. * the call that made it; a successful one is the caller's to remove, which it
  142. * derives from the returned path.
  143. *
  144. * @returns the absolute path of the materialized entry script.
  145. */
  146. function materializePyScripts(): string {
  147. const dir = mkdtempSync(join(tmpdir(), 'dsh-code-runtime-python-'))
  148. const source = fileURLToPath(new URL('../py/', import.meta.url))
  149. try {
  150. for (const name of PY_SCRIPTS) copyFileSync(join(source, name), join(dir, name))
  151. } catch (error: unknown) {
  152. try {
  153. rmSync(dir, { recursive: true, force: true })
  154. } catch {
  155. // Swallows only a failure to remove the partial staging directory. The
  156. // caller reports the copy failure that got us here, which is the
  157. // diagnosable one; nothing else can act on a temp dir we cannot unlink.
  158. }
  159. throw error
  160. }
  161. return join(dir, 'bootstrap.py')
  162. }
  163. /**
  164. * The fd-3 receive ceiling for one unframed line: a pure host-memory-safety
  165. * bound, NOT an output budget. Binding `call` frames legitimately carry large
  166. * arguments (the seam puts no byte cap on binding traffic), so the ceiling
  167. * must sit far above any plausible frame while still stopping a hostile
  168. * newline-free flood from growing the host accumulator without bound — the
  169. * child's RLIMIT_AS bounds the child, not the host string. 256 MiB mirrors
  170. * the order of the worker backend's default outer-output cap and V8's string
  171. * ceiling neighborhood; completion values have their own `maxValueBytes`
  172. * check at the `done` handler, deliberately decoupled from this. Not a config
  173. * knob because it is an internal framing invariant, not a deployment choice.
  174. */
  175. const FRAME_CEILING_BYTES = 256 * 1024 * 1024
  176. /**
  177. * Fragments the unframed fd-3 buffer may hold before they are coalesced into
  178. * one Buffer, bounding retained per-chunk overhead that {@link
  179. * FRAME_CEILING_BYTES} cannot see: that ceiling meters payload bytes, while
  180. * each chunk is a distinct Buffer with its own object and backing store. A
  181. * program writing single bytes without a newline produced one chunk per write.
  182. * 1024 keeps the overhead a small constant factor of the payload while leaving
  183. * normal pipe-sized reads (which arrive in far fewer, much larger chunks)
  184. * untouched. A framing invariant, not a deployment choice.
  185. */
  186. const MAX_PENDING_CHUNKS = 1024
  187. /**
  188. * Bytes a frame spends on its own JSON structure around a capped payload, used
  189. * to bound `maxLogBytes`/`maxValueBytes` against {@link FRAME_CEILING_BYTES}.
  190. * The widest carrier is `{"type":"log","text":"","truncated":true}` at 41
  191. * bytes; 64 rounds that up so adding a field to either frame does not silently
  192. * invalidate the bound. A protocol constant, not a deployment choice.
  193. */
  194. const FRAME_ENVELOPE_BYTES = 64
  195. /**
  196. * Extra time added to `graceMs` before the post-kill close-deadline force-settles
  197. * a run whose `close` never fires (a setsid-escaped orphan holds our inherited
  198. * stdio; see the `closeDeadline` arm in {@link PythonCodeRuntime.execute}). It
  199. * covers the OS reaping the killed child itself after SIGKILL — not a deployment
  200. * choice but a fixed safety margin, so it is a constant rather than a config knob.
  201. */
  202. const CLOSE_REAP_MARGIN_MS = 2_000
  203. /**
  204. * Interval between process-group liveness probes while settlement waits for an
  205. * escalated SIGKILL to empty the group (see the `killing` branch in
  206. * {@link PythonCodeRuntime.execute}'s settle). A poll rather than an event
  207. * because the group members are the model's own descendants, which the host does
  208. * not `wait()` for and gets no exit signal from; the probe is a signal-0
  209. * `process.kill(-pid, 0)`, so the interval only bounds how promptly a now-empty
  210. * group is noticed, capped by `graceMs + CLOSE_REAP_MARGIN_MS`.
  211. */
  212. const GROUP_REAP_POLL_MS = 50
  213. /**
  214. * Extract a human message from an unknown thrown value.
  215. *
  216. * `String(error)` runs the value's own conversion, and a host binding may reject
  217. * with an object whose `Symbol.toPrimitive` or `toString` throws. One call site
  218. * is a detached async reply callback, where that throw escapes as an unhandled
  219. * rejection: the reply frame is never written, the program stays blocked on
  220. * `await`, and the run degrades to a `maxWallMs` timeout (a Node host without an
  221. * `unhandledRejection` listener exits outright). The conversion is therefore
  222. * wrapped, with a fixed literal as the fallback — the value already proved it
  223. * cannot be rendered, so nothing derived from it is safe to try.
  224. *
  225. * `Error.message` is typed `string` but is a plain writable property, so a
  226. * rejecting binding can hand back an `Error` carrying any value there. The
  227. * `Error` arm therefore goes through the same conversion rather than returning
  228. * `message` verbatim: the returned string crosses the wire under
  229. * `encodeJsonPlain`'s JSON-plain precondition, where a cyclic object grows the
  230. * encoder stack until the host exhausts memory and any other unsupported value
  231. * prevents the reply frame outright.
  232. *
  233. * The same conversion renders abort reasons, which reach an `AbortSignal`
  234. * listener: Node reports a throw from such a listener as an uncaught exception,
  235. * so an unwrapped conversion there can terminate the host with the run left
  236. * unsettled.
  237. *
  238. * @param error The thrown value, of unknown shape.
  239. * @returns The value's message or string form; a fixed placeholder when its own
  240. * conversion throws.
  241. */
  242. function messageOf(error: unknown): string {
  243. try {
  244. return String(error instanceof Error ? error.message : error)
  245. } catch {
  246. // Swallows only a throw from the value's own `message` getter or string
  247. // conversion. Nothing else runs inside the try, and the placeholder is a
  248. // literal, so this cannot throw again.
  249. return '<unrenderable rejection value>'
  250. }
  251. }
  252. /**
  253. * Resolve `pythonBin` to an absolute path against the CURRENT process `PATH`,
  254. * BEFORE the child spawns with an empty environment. A basename (the default
  255. * `python3`) would otherwise fail: `env: {}` drops `PATH`, so Node's own lookup
  256. * falls back to the platform default (`/usr/bin:/bin`) and misses interpreters
  257. * that live only on the caller's `PATH` (Nix, pyenv, Homebrew, conda). An
  258. * absolute or explicitly relative path is used verbatim. When no `PATH` entry
  259. * holds an executable match, the original value is returned unchanged so the
  260. * spawn produces its normal ENOENT `error` event (a settled `worker-exit`),
  261. * not a thrown exception here.
  262. * @param bin - the configured interpreter (absolute path or bare command).
  263. * @returns an absolute path when resolvable, else `bin` unchanged.
  264. */
  265. function resolvePythonBin(bin: string): string {
  266. if (isAbsolute(bin) || bin.includes('/')) return bin
  267. const path = process.env.PATH
  268. /* v8 ignore next -- PATH is set in every environment the runtime boots in; the guard is defensive. */
  269. if (path === undefined) return bin
  270. for (const dir of path.split(delimiter)) {
  271. // An empty PATH segment (a `::`, implicitly CWD on POSIX) is skipped so a
  272. // basename never resolves against the working directory; normal PATHs
  273. // carry no empty segment.
  274. /* v8 ignore next -- normal PATHs carry no empty segment. */
  275. if (dir === '') continue
  276. const candidate = join(dir, bin)
  277. try {
  278. accessSync(candidate, fsConstants.X_OK)
  279. return candidate
  280. } catch {
  281. // Not executable here; try the next PATH entry.
  282. }
  283. }
  284. return bin
  285. }
  286. /** The marker appended when a diagnostic message is byte-capped host-side. */
  287. const TRUNCATION_MARKER = '… [truncated]'
  288. /**
  289. * The marker's own UTF-8 byte length, reserved out of the budget so a capped
  290. * message stays WITHIN `maxValueBytes` rather than exceeding it by the marker.
  291. * The ellipsis is 3 bytes, so this is 15, not the string's 13 code units.
  292. */
  293. const TRUNCATION_MARKER_BYTES = Buffer.byteLength(TRUNCATION_MARKER, 'utf8')
  294. /**
  295. * Serialized JSON-string cost of `text` (the two quotes plus each character's
  296. * escaped byte width), measured WITHOUT materializing the escaped copy, and
  297. * abandoned the instant it exceeds `maxBytes`. `JSON.stringify(text)` would
  298. * allocate the whole escaped form first — up to sixfold a control-char-dense
  299. * string — so a near-budget line under a large `maxLogBytes` could momentarily
  300. * allocate over a gigabyte just to measure it. This walks code point by code
  301. * point and stops at the cap, so the measurement allocates nothing.
  302. * @param text - the candidate string.
  303. * @param maxBytes - the largest serialized size the caller can admit.
  304. * @returns the exact serialized byte cost, or `undefined` once it exceeds `maxBytes`.
  305. */
  306. function jsonStringCostUpTo(text: string, maxBytes: number): number | undefined {
  307. let bytes = 2 // the enclosing quotes
  308. for (const character of text) {
  309. const code = character.codePointAt(0) as number
  310. // Control characters below 0x20 escape to `\uXXXX` (6) except the five with
  311. // short forms `\b \t \n \f \r` (2); `"` and `\` escape to 2; everything else
  312. // rides at its raw UTF-8 width.
  313. if (code < 0x20) {
  314. bytes += code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d ? 2 : 6
  315. } else if (code === 0x22 || code === 0x5c) {
  316. bytes += 2
  317. } else {
  318. bytes += Buffer.byteLength(character, 'utf8')
  319. }
  320. if (bytes > maxBytes) return undefined
  321. }
  322. return bytes
  323. }
  324. /**
  325. * Cap a done-frame `error.message` to `maxValueBytes` host-side: a forged done
  326. * frame can carry an arbitrarily long message, so truncate by RAW UTF-8 byte
  327. * length and append the shared marker on overflow. Completion VALUES are never
  328. * truncated — the seam forbids substitution, so an oversized value fails the run
  329. * as `output-limit` instead (see the done case in `execute`).
  330. *
  331. * This is the RECEIVE-side backstop, and it bills by raw bytes on purpose,
  332. * unlike the producing-side `_cap_message` in `py/bootstrap.py`, which bills by
  333. * SERIALIZED (JSON-escaped) cost. The split is deliberate: `_cap_message`'s
  334. * output has to cross fd 3 as a JSON string, so its escaped width is what the
  335. * frame ceiling bounds; this function's output goes straight into
  336. * `CodeRunResult.error.message` and never re-crosses a frame-bounded channel, so
  337. * the honest measure of what it retains is the raw length. An honest child has
  338. * already capped the diagnostic by serialized cost, and raw length ≤ serialized
  339. * cost, so a well-formed message passes through unchanged. A forged message with
  340. * control characters could serialize to roughly six times its raw length, but it
  341. * is not travelling any capped channel, so the raw-byte bound is the right one:
  342. * the value it protects is the model-visible size of `error.message`, not a wire
  343. * width.
  344. *
  345. * The marker's bytes are RESERVED from the budget, not added on top: the whole
  346. * returned string, marker included, is at most `maxValueBytes` bytes. Appending
  347. * the marker after retaining a full budget's worth of text would overrun the
  348. * very cap this function exists to enforce. The one exception is a configured
  349. * cap SMALLER than the marker itself, which leaves no room for message text at
  350. * all; the marker alone is returned there, so the bound is
  351. * `max(maxValueBytes, 15)`. Reporting the truncation is worth those 15 bytes,
  352. * and the default cap is 32 KiB.
  353. * @param message - the error message from an inbound (possibly forged) done frame.
  354. * @param maxValueBytes - the configured completion-value budget, reused here.
  355. * @returns the message unchanged, or its byte-capped form on overflow.
  356. */
  357. function capMessage(message: string, maxValueBytes: number): string {
  358. // Code-unit bounds BEFORE any encode, so a forged done frame carrying a
  359. // message anywhere below the 256 MiB fd-3 frame ceiling cannot force a
  360. // full-length UTF-8 copy under a 32 KiB cap. One UTF-16 code unit encodes to
  361. // at least one UTF-8 byte and at most three: three for a non-ASCII BMP
  362. // character, two apiece for the pair halves sharing an astral code point's
  363. // four bytes, and three for a LONE surrogate, which `Buffer.from` renders as
  364. // U+FFFD. So at most maxValueBytes/3 code units cannot overflow the cap and
  365. // need no encode at all...
  366. if (message.length * 3 <= maxValueBytes) return message
  367. // ...and nothing past the first maxValueBytes code units can fit inside it,
  368. // so only that prefix is ever encoded — at most 3 * maxValueBytes bytes.
  369. const keep = Math.min(message.length, maxValueBytes)
  370. const whole = keep === message.length
  371. const bytes = Buffer.from(whole ? message : message.slice(0, keep), 'utf8')
  372. // A message that fits is measured against the WHOLE cap: it gets no marker,
  373. // so reserving marker bytes here would truncate text that was within budget.
  374. if (whole && bytes.length <= maxValueBytes) return message
  375. // Past this point the message IS being truncated, so the marker WILL be
  376. // appended and its bytes come out of the cap instead of sitting on top of it.
  377. const budget = Math.max(0, maxValueBytes - TRUNCATION_MARKER_BYTES)
  378. // Trim back to the last complete UTF-8 sequence: a cut through a multibyte
  379. // character would decode as U+FFFD — corrupting the diagnostic AND
  380. // exceeding the byte cap, since the replacement character itself encodes
  381. // to three bytes. Continuation bytes are 0b10xxxxxx; at most three of them
  382. // precede a lead byte.
  383. //
  384. // This also covers a code-unit prefix ending on a HIGH SURROGATE whose low
  385. // half sits outside it, which `Buffer.from` encodes as U+FFFD: that orphan
  386. // occupies the last three bytes of `bytes`, and `bytes` is at least
  387. // `maxValueBytes + 2` long here (one byte per retained unit, three for the
  388. // orphan), so it starts past `budget` and is always cut. Reserving the
  389. // marker is what makes that hold; cutting at `maxValueBytes` itself did not,
  390. // and needed an explicit surrogate check.
  391. let end = Math.min(budget, bytes.length)
  392. while (end > 0 && ((bytes[end] as number) & 0b1100_0000) === 0b1000_0000) end--
  393. return `${bytes.subarray(0, end).toString('utf8')}${TRUNCATION_MARKER}`
  394. }
  395. /**
  396. * Copy an fd-3 line residual into a fresh, right-sized Buffer so it no longer
  397. * shares the joined-frame allocation it was sliced from.
  398. *
  399. * After the newline loop over a `Buffer.concat` of the pending chunks, the
  400. * leftover partial line is a `subarray` VIEW onto that concat's backing store.
  401. * A view keeps the ENTIRE backing allocation alive for as long as it is
  402. * retained, so carrying the view forward as the next pending chunk would pin a
  403. * whole large frame's worth of memory behind a tiny trailing fragment — and the
  404. * `pendingBytes` counter, set to the fragment's own length, would no longer
  405. * measure the memory actually held. `Buffer.from` allocates exactly
  406. * `residual.length` bytes and copies, letting the concat allocation be
  407. * collected; an empty residual carries nothing forward.
  408. * @param residual - the leftover slice after the last newline (a view).
  409. * @returns the pending-chunk list to carry forward: `[copy]`, or `[]` when empty.
  410. */
  411. export function detachResidual(residual: Buffer): Buffer[] {
  412. return residual.length > 0 ? [Buffer.from(residual)] : []
  413. }
  414. /** One namespace after seam validation: its callables plus the optional typed-rejection contract. */
  415. interface ValidatedNamespace {
  416. functions: Record<string, CodeBindingFunction>
  417. errorClass?: CodeBindingErrorClass
  418. }
  419. /**
  420. * One in-flight run's host-side state, tracked for disposal so teardown can
  421. * fail every live run as `abort` and AWAIT each child's exit.
  422. */
  423. interface LiveRun {
  424. kill(sig: NodeJS.Signals): void
  425. settle(failure: CodeRunFailure): void
  426. finished: Promise<void>
  427. }
  428. /**
  429. * The shipped {@link CodeRuntime} backend registering as `codeRuntime`. Every
  430. * cap is validated config; every long-running operation honors the request's
  431. * `AbortSignal`; every disposer awaits child-process exit.
  432. */
  433. export class PythonCodeRuntime extends CodeRuntime {
  434. static Config: z<Config> = z.object({
  435. cpuSeconds: z.number().default(60),
  436. maxWallMs: z.number().default(600_000),
  437. addressSpaceMb: z.number().default(512),
  438. maxLogBytes: z.number().default(65_536),
  439. maxValueBytes: z.number().default(32_768),
  440. graceMs: z.number().default(3_000),
  441. pythonBin: z.string().default('python3'),
  442. })
  443. readonly language = 'python'
  444. readonly isolation = 'process'
  445. private readonly config: ResolvedConfig
  446. private readonly live = new Set<LiveRun>()
  447. private disposed = false
  448. /* jscpd:ignore-start -- parallel to code-runtime-worker: sibling backends keep symmetric constructor/teardown/run shapes. */
  449. constructor(ctx: Context, config: Config) {
  450. super(ctx)
  451. // Reject at load on Windows: the bootstrap imports the POSIX-only `resource`
  452. // module for RLIMIT_CPU/RLIMIT_AS, spawns with a positional fd 3, and
  453. // terminates via negative-PID process-group signals — none of which exist
  454. // on Windows. Registering ctx.codeRuntime there would let assembly succeed
  455. // and defer the failure to the first run. The asymmetry with the worker
  456. // backend is intentional: that backend is cross-platform; this one is not.
  457. if (process.platform === 'win32') {
  458. throw new Error('dsh-code-runtime-python: this backend requires a Unix platform (POSIX rlimits, fd-3 stdio, process-group signals); it cannot run on Windows')
  459. }
  460. this.config = config as ResolvedConfig
  461. for (const [key, value] of Object.entries(this.config)) {
  462. if (typeof value === 'number' && !(Number.isFinite(value) && value > 0)) {
  463. throw new Error(`dsh-code-runtime-python: config.${key} must be a positive number, got ${String(value)}`)
  464. }
  465. }
  466. // cpuSeconds crosses to the child's setrlimit(RLIMIT_CPU) raw; a float
  467. // raises TypeError inside every child (a late per-run failure). Reject it
  468. // at load. maxLogBytes/maxValueBytes get their own integer gate below (the
  469. // child int()-truncates them, so a float would diverge from the host);
  470. // maxWallMs/graceMs/addressSpaceMb are consumed as numbers where a fraction
  471. // is harmless.
  472. if (!Number.isInteger(this.config.cpuSeconds)) {
  473. throw new Error(`dsh-code-runtime-python: config.cpuSeconds must be a positive integer, got ${String(this.config.cpuSeconds)}`)
  474. }
  475. // Finite is not the same as representable as an rlimit. `cpuSeconds` and its
  476. // `+ 1` hard limit both cross to `setrlimit` as integers, and `1e100` clears
  477. // `Number.isInteger` while being far past the safe range, so it cannot round
  478. // -trip: the child sees a different number than was configured. The `+ 1` is
  479. // what gets checked because that is the larger of the two values sent.
  480. if (!Number.isSafeInteger(this.config.cpuSeconds + 1)) {
  481. throw new Error(`dsh-code-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)}`)
  482. }
  483. // `addressSpaceMb` is multiplied by 1 MiB before it is framed, and a large
  484. // finite value overflows to `Infinity` there — which `encodeJsonPlain`
  485. // renders as `null`, so the child receives no limit at all and every run
  486. // ends in a bootstrap exception rather than a load-time configuration error.
  487. // Checking the DERIVED byte count is what catches it; the input itself looks
  488. // ordinary. Safe-integer, not merely finite, since the value must survive
  489. // the JSON round trip exactly.
  490. if (!Number.isSafeInteger(this.config.addressSpaceMb * 1024 * 1024)) {
  491. throw new Error(`dsh-code-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)}`)
  492. }
  493. // `pythonBin` reaches `spawn` as the executable path, where two values the
  494. // string schema admits fail late and unhelpfully. An empty string makes
  495. // `spawn` throw `ERR_INVALID_ARG_VALUE` synchronously, and an embedded NUL
  496. // throws `ERR_INVALID_ARG_TYPE` — both from inside `run()`, so the method
  497. // REJECTS instead of resolving the `worker-exit` the seam promises for a
  498. // child that cannot start. An empty basename also makes `resolvePythonBin`
  499. // probe every PATH directory itself for the X_OK bit. Both are
  500. // self-contained configuration errors, so they fail at load.
  501. if (this.config.pythonBin === '' || this.config.pythonBin.includes('\0')) {
  502. throw new Error(`dsh-code-runtime-python: config.pythonBin must be a non-empty path without NUL bytes, got ${JSON.stringify(this.config.pythonBin)}`)
  503. }
  504. // `maxWallMs` and `graceMs` are armed with setTimeout, which clamps any
  505. // delay past MAX_TIMER_DELAY_MS to 1 ms without a word — turning a
  506. // generous ceiling into an instant timeout and a generous grace period into
  507. // an instant SIGKILL. `graceMs` is checked against the margin the
  508. // close-deadline adds on top, since that sum is what gets armed.
  509. if (this.config.maxWallMs > MAX_TIMER_DELAY_MS) {
  510. throw new Error(`dsh-code-runtime-python: config.maxWallMs must not exceed ${MAX_TIMER_DELAY_MS} (setTimeout clamps a larger delay to 1ms), got ${String(this.config.maxWallMs)}`)
  511. }
  512. if (this.config.graceMs + CLOSE_REAP_MARGIN_MS > MAX_TIMER_DELAY_MS) {
  513. throw new Error(`dsh-code-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)}`)
  514. }
  515. // The output caps are budgets for a payload that has to cross fd 3 inside
  516. // one frame, and the framing ceiling is fixed. A cap above what a frame can
  517. // carry is unsatisfiable: a completion or log entry that the cap admits
  518. // arrives as an over-ceiling frame and fails the run as `worker-exit`
  519. // instead of the `output-limit` the cap describes — a silent inversion, so
  520. // it fails at load. Both budgets are metered in SERIALIZED (JSON-escaped)
  521. // bytes — the host log ledger charges `Buffer.byteLength(JSON.stringify(text))`,
  522. // `checkDoneValue` measures the escaped form, and the producing-side
  523. // `_cap_message` in the child also caps by serialized cost (which is why a
  524. // capped diagnostic still fits its frame) — so a payload admitted under the
  525. // cap occupies at most `cap + envelope` bytes on the wire; escaping is
  526. // already inside the charge and must not be multiplied in again. The
  527. // receive-side `capMessage` backstop is the one exception to this argument:
  528. // it bills a forged `done.error.message` by RAW bytes, but that output goes
  529. // into `CodeRunResult.error.message` and never re-crosses a frame-bounded
  530. // channel, so it is not part of the wire-width bound (see its JSDoc). The
  531. // admissible cap is therefore `ceiling - envelope`.
  532. for (const key of ['maxLogBytes', 'maxValueBytes'] as const) {
  533. // Require an integer: the child reads these budgets through `int(...)`,
  534. // which silently floors a float, so `maxLogBytes: 3.5` would truncate at 3
  535. // bytes child-side while the host meters and marks at 3.5 — the two sides
  536. // enforcing different public config. Reject the float at load, as the
  537. // worker backend does for its byte budgets.
  538. if (!Number.isInteger(this.config[key])) {
  539. throw new Error(`dsh-code-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])}`)
  540. }
  541. const limit = FRAME_CEILING_BYTES - FRAME_ENVELOPE_BYTES
  542. if (this.config[key] > limit) {
  543. throw new Error(`dsh-code-runtime-python: config.${key} must not exceed ${limit} (a payload that large cannot cross the ${FRAME_CEILING_BYTES}-byte fd-3 frame ceiling, so the run would fail as worker-exit rather than output-limit), got ${String(this.config[key])}`)
  544. }
  545. }
  546. ctx.effect(() => () => this.teardown(), 'python code-runtime teardown')
  547. }
  548. /**
  549. * Dispose to quiescence: fail every in-flight run as aborted and AWAIT each
  550. * child's exit so no subprocess that stays in the child's process group
  551. * outlives the fiber. A descendant that escaped the group with `setsid()` /
  552. * `start_new_session=True` is unreachable by `kill(-pid)` and is the documented
  553. * exception (see the package README's Known Limitations); the process-group
  554. * teardown reaps everything that stays in the group.
  555. */
  556. private async teardown(): Promise<void> {
  557. this.disposed = true
  558. const runs = [...this.live]
  559. for (const run of runs) run.settle({ kind: 'abort', message: 'runtime disposed' })
  560. // Awaiting `finished` is also what clears staging: that promise resolves
  561. // inside the run's own `settle`, which removes its directory first. So there
  562. // is deliberately no sweep here — a second pass could only ever find an
  563. // empty set, and an unreachable cleanup path is worse than none, since it
  564. // reads as the real guarantee while never running.
  565. await Promise.all(runs.map(run => run.finished))
  566. }
  567. /**
  568. * Execute one program in a fresh Python subprocess. Program outcomes resolve
  569. * with `result.error`; the method rejects only for seam misuse.
  570. */
  571. async run(request: CodeRunRequest): Promise<CodeRunResult> {
  572. if (this.disposed) throw new Error('dsh-code-runtime-python: run() after disposal')
  573. const bindings = this.validateBindings(request)
  574. if (request.signal?.aborted) {
  575. return { logs: [], error: { kind: 'abort', message: messageOf(request.signal.reason) } }
  576. }
  577. let bootstrapPath: string
  578. try {
  579. // The interpreter is an external process, so the entry script has to sit
  580. // on the real filesystem; see materializePyScripts. One copy PER RUN,
  581. // synchronously, so no async boundary opens before `execute` registers the
  582. // run and installs the abort listener.
  583. bootstrapPath = materializePyScripts()
  584. } catch (error: unknown) {
  585. // A full or read-only temp filesystem, or a packaged asset the deployment
  586. // failed to ship, is a SUBSTRATE failure — the same class as a child that
  587. // cannot start. The seam permits rejection only for misuse, so this
  588. // resolves as `worker-exit` rather than throwing out of `run()`.
  589. return { logs: [], error: { kind: 'worker-exit', message: `failed to stage the python bootstrap: ${messageOf(error)}` } }
  590. }
  591. return await this.execute(request, bindings, bootstrapPath)
  592. }
  593. /* jscpd:ignore-end */
  594. /**
  595. * Reject (seam misuse) malformed binding namespaces: non-identifier or
  596. * reserved globals/error classes, duplicates, and colliding or
  597. * runtime-owned injected globals.
  598. */
  599. private validateBindings(request: CodeRunRequest): Map<string, ValidatedNamespace> {
  600. const bindings = new Map<string, ValidatedNamespace>()
  601. // Every name the bootstrap injects into the program's one global namespace:
  602. // namespace globals plus error-class names. They must be a collision-free
  603. // set that avoids the runtime's own slots, or a later injection silently
  604. // overwrites an earlier one (or the completion/builtins slot) and the run
  605. // fails obscurely at execution time.
  606. const injectedGlobals = new Set<string>()
  607. const claimGlobal = (name: string, role: string): void => {
  608. if (RUNTIME_OWNED_GLOBALS.has(name)) {
  609. throw new Error(`dsh-code-runtime-python: ${role} ${JSON.stringify(name)} collides with a runtime-owned global`)
  610. }
  611. if (injectedGlobals.has(name)) {
  612. throw new Error(`dsh-code-runtime-python: ${role} ${JSON.stringify(name)} collides with another injected global`)
  613. }
  614. injectedGlobals.add(name)
  615. }
  616. for (const namespace of request.bindings) {
  617. if (!IDENTIFIER.test(namespace.global) || RESERVED_NAMES.has(namespace.global)) {
  618. throw new Error(`dsh-code-runtime-python: binding global ${JSON.stringify(namespace.global)} is not a usable Python identifier`)
  619. }
  620. if (bindings.has(namespace.global)) {
  621. throw new Error(`dsh-code-runtime-python: duplicate binding global ${JSON.stringify(namespace.global)}`)
  622. }
  623. claimGlobal(namespace.global, 'binding global')
  624. // The error class becomes a program global and its member property an
  625. // attribute name, so both face the Python identifier rules; the member
  626. // additionally must be assignable on a BaseException instance.
  627. const errorClass = namespace.errorClass
  628. if (errorClass) {
  629. if (!IDENTIFIER.test(errorClass.name) || RESERVED_NAMES.has(errorClass.name)) {
  630. throw new Error(`dsh-code-runtime-python: errorClass.name ${JSON.stringify(errorClass.name)} is not a usable Python identifier`)
  631. }
  632. // Any non-empty own attribute name is settable via setattr (the
  633. // program reads exotic names like `tool-name` with getattr), matching
  634. // the seam contract and the worker backend — only the seam-excluded
  635. // and protocol-reserved members below are refused.
  636. if (errorClass.memberNameProperty.length === 0) {
  637. throw new Error('dsh-code-runtime-python: errorClass.memberNameProperty must be a non-empty attribute name')
  638. }
  639. if (EXCEPTION_RESERVED_MEMBERS.has(errorClass.memberNameProperty) || DUNDER.test(errorClass.memberNameProperty)) {
  640. throw new Error(`dsh-code-runtime-python: errorClass.memberNameProperty ${JSON.stringify(errorClass.memberNameProperty)} is a reserved error member and cannot be assigned`)
  641. }
  642. claimGlobal(errorClass.name, 'errorClass.name')
  643. }
  644. bindings.set(namespace.global, { functions: namespace.functions, ...errorClass ? { errorClass } : {} })
  645. }
  646. return bindings
  647. }
  648. /** Spawn the child for one validated run and drive it to settlement. */
  649. private execute(
  650. request: CodeRunRequest,
  651. bindings: Map<string, ValidatedNamespace>,
  652. bootstrapPath: string,
  653. ): Promise<CodeRunResult> {
  654. // This run's own staging directory, removed at settlement.
  655. const bootstrapDir = dirname(bootstrapPath)
  656. // Explicit pipe count of 4 puts the framed-JSON channel at fd 3 in the child.
  657. // Resolve the interpreter against the current PATH first: the child's empty
  658. // env would otherwise strip PATH and miss a basename python3 (see resolvePythonBin).
  659. // `spawn` can throw SYNCHRONOUSLY — a descriptor-exhausted host (EMFILE) or a
  660. // libuv-level failure surfaces here, before the Promise executor and its
  661. // settlement path exist. Left uncaught it would REJECT run() (the seam
  662. // permits rejection only for misuse) and strand this run's staging directory,
  663. // which only settle() removes. Catch it, unlink the directory, and resolve a
  664. // `worker-exit` — the same class as the async ENOENT `error` event below.
  665. let child: ChildProcessWithoutNullStreams
  666. let proto: Duplex | null
  667. try {
  668. child = spawn(resolvePythonBin(this.config.pythonBin), ['-I', bootstrapPath], {
  669. env: {},
  670. detached: true, // Own process group — kill(-pid, sig) reaches subprocesses the model program spawns.
  671. stdio: ['pipe', 'pipe', 'pipe', 'pipe'],
  672. })
  673. // Fd 3 is a duplex pipe carrying protocol frames. Node types extra stdio
  674. // entries as `Stream | null`; the runtime shape with `'pipe'` is a duplex,
  675. // so we narrow at the boundary rather than smearing casts below. Stdout
  676. // and stderr are guaranteed non-null under `'pipe'` and typed as such.
  677. proto = child.stdio[3] as Duplex | null
  678. /* v8 ignore next 3 -- `'pipe'` stdio always populates fd 3; guarding Node's `Stream | null` typing widening. */
  679. if (proto === null) {
  680. throw new Error('dsh-code-runtime-python: python subprocess spawned without a fd-3 pipe')
  681. }
  682. } catch (error: unknown) {
  683. try {
  684. rmSync(bootstrapDir, { recursive: true, force: true })
  685. } catch {
  686. // Same swallow as settle()'s removal: `force` already absorbs a missing
  687. // directory, so only a filesystem-level refusal reaches here, and the
  688. // staging copy holds nothing but two checked-in scripts.
  689. }
  690. return Promise.resolve({ logs: [], error: { kind: 'worker-exit' as const, message: `python spawn error: ${messageOf(error)}` } })
  691. }
  692. return new Promise<CodeRunResult>((resolve) => {
  693. let settled = false
  694. const logs: string[] = []
  695. // One host-side ledger covers normal frames, forged frames, and stray stdout bytes.
  696. let logBudget = this.config.maxLogBytes
  697. let logsTruncated = false
  698. const admit = (text: string): void => {
  699. /* v8 ignore next -- post-truncation admits no-op; needs child to keep streaming after ledger drops. */
  700. if (logsTruncated) return
  701. // Each entry is charged its SERIALIZED cost — JSON.stringify's quotes
  702. // and escapes plus one separator byte — because the seam bounds the
  703. // serialized outer logs payload, and control characters expand
  704. // several-fold under JSON escaping (a "\x00" flood would otherwise
  705. // admit 6x its charge). The charge also puts a floor under an empty
  706. // entry (its two quotes plus separator), so a `while True: print()`
  707. // flood of zero-byte lines exhausts the ledger instead of growing the
  708. // retained array without ever touching the budget. The one fixed
  709. // truncation-marker entry is envelope, not payload, and rides
  710. // uncharged.
  711. //
  712. // Cheap lower bound FIRST, before the escaped copy exists: every
  713. // UTF-16 code unit costs at least one serialized byte (an ASCII
  714. // character is one byte; a control character is six as `\uXXXX`; a
  715. // non-ASCII BMP character is two or three; each half of a surrogate
  716. // pair contributes two of the four bytes its code point encodes to),
  717. // and the JSON form adds two quotes on top of the separator byte. So
  718. // `text.length + 3` never exceeds the true cost, and a forged `log`
  719. // frame carrying a control-heavy string anywhere below the 256 MiB
  720. // frame ceiling truncates here instead of allocating a
  721. // hundreds-of-megabytes escaped copy under a small maxLogBytes.
  722. if (text.length + 3 > logBudget) {
  723. logsTruncated = true
  724. logs.push(logTruncationMarker(this.config.maxLogBytes))
  725. return
  726. }
  727. // Past the lower bound, measure the exact serialized cost without
  728. // allocating the escaped copy: `jsonStringCostUpTo` walks to the cap and
  729. // stops, so even a near-budget control-char-dense line never materializes
  730. // a sixfold-inflated `JSON.stringify` result. `+ 1` for the separator.
  731. const measured = jsonStringCostUpTo(text, logBudget - 1)
  732. if (measured === undefined) {
  733. logsTruncated = true
  734. logs.push(logTruncationMarker(this.config.maxLogBytes))
  735. return
  736. }
  737. logBudget -= measured + 1
  738. logs.push(text)
  739. }
  740. // Stray-byte capture: anything the child writes to its stdout/stderr
  741. // (native prints, C-extension writes) still counts against the ledger.
  742. //
  743. // Output is admitted per LINE, not per transport chunk. `logs` entries
  744. // are joined with `\n` downstream (Code Mode), so each entry must be one
  745. // line: pushing a raw `data` chunk would turn every arbitrary pipe-read
  746. // boundary into a model-visible newline, so a single 200 KiB native write
  747. // split across pipe reads would read back with spurious line breaks. The
  748. // child's own `log` frames are already line-granular; stray capture
  749. // matches them by splitting on `\n`.
  750. //
  751. // Buffered as raw `Buffer` chunks with a running byte counter, exactly
  752. // like the fd-3 reader below and for the same reasons: a string `+=`
  753. // accumulator re-copies the whole residual on every pipe chunk (quadratic
  754. // on a large newline-free write), and scanning it from index 0 each chunk
  755. // is a second quadratic. Appending a chunk is O(1); the split happens only
  756. // when a `\n` actually arrived. A newline never appears inside a UTF-8
  757. // multibyte sequence (continuation bytes are 0x80–0xBF), so splitting on
  758. // the raw 0x0a byte and decoding each complete line is safe without a
  759. // streaming decoder — a line's bytes are whole by construction.
  760. interface StrayBuffer { chunks: Buffer[]; bytes: number }
  761. const strayOut: StrayBuffer = { chunks: [], bytes: 0 }
  762. const strayErr: StrayBuffer = { chunks: [], bytes: 0 }
  763. const captureStray = (stray: StrayBuffer, chunk: Buffer): void => {
  764. // Once the ledger has truncated, stop buffering: admit() is a no-op past
  765. // that point, so continuing to accumulate would retain host memory for
  766. // output that can never be admitted.
  767. if (logsTruncated) return
  768. stray.chunks.push(chunk)
  769. stray.bytes += chunk.length
  770. if (chunk.includes(0x0a)) {
  771. let buffered = Buffer.concat(stray.chunks)
  772. let newline: number
  773. while ((newline = buffered.indexOf(0x0a)) >= 0) {
  774. admit(buffered.subarray(0, newline).toString('utf8'))
  775. buffered = buffered.subarray(newline + 1)
  776. }
  777. // Carry the residual as a fresh right-sized copy, not the subarray view
  778. // (which would pin the whole concat allocation). See detachResidual.
  779. stray.chunks = detachResidual(buffered)
  780. stray.bytes = buffered.length
  781. }
  782. // Newline-free residual is bounded by the ledger, not left to grow with
  783. // the stream: an `os.write(1, b"A"*N)` flood carrying no newline would
  784. // otherwise accumulate N bytes in host memory before `end`. When the
  785. // pending residual would cross the budget, admit it now — admit() charges
  786. // its serialized cost, truncates, and marks the ledger, and the
  787. // truncation short-circuit above stops buffering on the next chunk. The
  788. // raw byte count is a safe lower bound on the serialized cost, so this
  789. // fires no later than the budget is genuinely at risk.
  790. if (stray.bytes + 3 > logBudget) {
  791. flushStray(stray)
  792. }
  793. }
  794. // Flush a pipe's residual into `logs`. Called on the budget threshold
  795. // above, on the pipe's `end` (normal drain), and — for the setsid-escapee
  796. // path where destroy() forces settlement without an `end` — explicitly in
  797. // the closeDeadline handler. Idempotent: it clears what it admits, so a
  798. // later flush is a no-op. The `chunks.length` guard is the only emptiness
  799. // check needed — `data` never emits a zero-length Buffer, so a non-empty
  800. // chunk list always decodes to a non-empty tail.
  801. function flushStray(stray: StrayBuffer): void {
  802. if (stray.chunks.length === 0) return
  803. const tail = Buffer.concat(stray.chunks).toString('utf8')
  804. stray.chunks = []
  805. stray.bytes = 0
  806. admit(tail)
  807. }
  808. child.stdout.on('data', (chunk: Buffer) => { captureStray(strayOut, chunk) })
  809. child.stderr.on('data', (chunk: Buffer) => { captureStray(strayErr, chunk) })
  810. child.stdout.on('end', () => { flushStray(strayOut) })
  811. child.stderr.on('end', () => { flushStray(strayErr) })
  812. // Line-framed JSON reader over fd 3. The unframed buffer is bounded: a
  813. // hostile program can loop `os.write(3, b"A"*4096)` with no newline to
  814. // exhaust HOST memory, which the child's RLIMIT_AS does not cover. It is
  815. // a memory-safety bound only: legitimate `call` frames may be large
  816. // (binding traffic has no seam byte cap), so it never keys off
  817. // maxValueBytes.
  818. // Buffered as raw chunks with a running byte counter: appending is O(1)
  819. // per chunk (a string `+=` accumulator would re-copy the whole prefix on
  820. // every pipe chunk — quadratic on a large frame), joins happen only when
  821. // a newline actually arrived, and the ceiling check reads the counter.
  822. let pendingChunks: Buffer[] = []
  823. // Fragments already merged into finished blocks. Kept separate from
  824. // `pendingChunks` so sealing never re-copies what earlier seals produced;
  825. // the two together are the unframed buffer, and `pendingBytes` counts both.
  826. let sealedBlocks: Buffer[] = []
  827. let pendingBytes = 0
  828. proto.on('data', (chunk: Buffer) => {
  829. // Once settled, stop accumulating: a hostile child that keeps flooding
  830. // fd 3 between finish() and close must not regrow the host buffer.
  831. /* v8 ignore next -- post-settlement data needs the child to outrace close after we decided. */
  832. if (settled) return
  833. pendingChunks.push(chunk)
  834. pendingBytes += chunk.length
  835. // Check the counter BEFORE the join, not the joined line afterwards:
  836. // Buffer.concat allocates a second copy of everything held, so a line
  837. // measured after the concat had already cost twice the ceiling — the
  838. // ceiling this check exists to enforce. The counter is exact and free,
  839. // and the retained chunks are released here so the rejected payload is
  840. // not still held while the run settles.
  841. //
  842. // The counter charges the whole unframed buffer, which over-counts by at
  843. // most the newline-bearing chunk's own length (one pipe read): the
  844. // residual carried in is always a partial line, so nothing but the
  845. // current line can be larger than that. That over-count is deliberate and
  846. // load-bounded on the OTHER side: the config cap is `ceiling - envelope`,
  847. // and a legitimate near-cap frame plus a following chunk's leading bytes
  848. // could in principle nudge the counter over the ceiling for one read
  849. // window — but only when maxLogBytes/maxValueBytes is configured within
  850. // one pipe read of the 256 MiB ceiling, orders of magnitude past the
  851. // 32/64 KiB defaults. Enforcing the ceiling per-frame instead (splitting
  852. // before the check) would require `Buffer.concat`-ing an over-ceiling
  853. // single frame before rejecting it, reintroducing the peak-memory
  854. // doubling this pre-concat check and its regression tests exist to
  855. // prevent; the memory-safety bound against hostile input at any config
  856. // takes precedence over a false-reject reachable only at a pathological
  857. // near-ceiling config.
  858. if (pendingBytes > FRAME_CEILING_BYTES) {
  859. pendingChunks = []
  860. sealedBlocks = []
  861. pendingBytes = 0
  862. finish({ error: { kind: 'worker-exit', message: `protocol frame exceeded ${FRAME_CEILING_BYTES} bytes on fd 3` } })
  863. return
  864. }
  865. // Bound the FRAGMENT COUNT as well as the byte total, but only AFTER the
  866. // ceiling check above: sealing first would `Buffer.concat` an already
  867. // over-ceiling payload and allocate a second copy of it before the
  868. // rejection ran, which is the peak-memory doubling that check exists to
  869. // prevent.
  870. //
  871. // Fragment count needs its own bound because the ceiling meters payload
  872. // bytes only, while each retained chunk is a separate Buffer with object
  873. // and backing-store overhead no byte count sees: 5000 single-byte
  874. // newline-free writes produced 5000 chunks holding 5031 bytes, so a
  875. // program pacing such writes could accumulate millions of objects inside
  876. // the wall budget and exhaust the host heap far below the ceiling.
  877. //
  878. // Sealing appends to a list of finished blocks instead of re-merging
  879. // everything held. Concatenating the whole buffer at each threshold
  880. // re-copied the entire accumulated prefix every time, so the cumulative
  881. // copy volume was quadratic, not the amortized O(1) an earlier revision
  882. // of this comment claimed: 10 MiB trickled a byte at a time copies
  883. // 53.7 GB that way, and 64 MiB copies 2.2 TB. Here each byte is copied
  884. // once into its block and never again, so the total stays linear, and the
  885. // block list is itself bounded — every block holds at least
  886. // `MAX_PENDING_CHUNKS - 1` bytes, so reaching the 256 MiB ceiling admits
  887. // at most a few hundred thousand of them.
  888. if (pendingChunks.length >= MAX_PENDING_CHUNKS) {
  889. sealedBlocks.push(Buffer.concat(pendingChunks))
  890. pendingChunks = []
  891. }
  892. if (chunk.includes(0x0a)) {
  893. let buffered = Buffer.concat(sealedBlocks.length > 0 ? [...sealedBlocks, ...pendingChunks] : pendingChunks)
  894. sealedBlocks = []
  895. let newline: number
  896. while ((newline = buffered.indexOf(0x0a)) >= 0) {
  897. const line = buffered.subarray(0, newline)
  898. buffered = buffered.subarray(newline + 1)
  899. /* v8 ignore next -- an empty line comes only from a forged `\n\n` write. */
  900. if (line.length === 0) continue
  901. const text = line.toString('utf8')
  902. // JSON.parse would silently ROUND an integer token outside the
  903. // safe range before validation could see it, so a forged frame
  904. // could smuggle a corrupted value into a dispatch or completion.
  905. // An honest child never emits one (its validator rejects unsafe
  906. // ints), so such a frame is hostile traffic: drop it like any
  907. // other junk frame.
  908. if (hasUnsafeIntegerToken(text)) continue
  909. let parsed: unknown
  910. try {
  911. parsed = JSON.parse(text) as unknown
  912. } catch {
  913. continue // Junk frames drop silently (hostile-peer stance).
  914. }
  915. const message = validateChildFrame(parsed)
  916. if (message) handleFrame(message)
  917. }
  918. // Carry the residual forward as a fresh, right-sized copy, NOT the
  919. // `subarray` view: a view keeps the whole joined-frame allocation from
  920. // the `Buffer.concat` above alive, so a large frame followed by a tiny
  921. // trailing fragment would pin megabytes while `pendingBytes` reported
  922. // only the fragment's length. See {@link detachResidual}.
  923. pendingChunks = detachResidual(buffered)
  924. pendingBytes = buffered.length
  925. }
  926. })
  927. // Duplicate-call suppression against the honest child's id SEQUENCE, not
  928. // a set of every id seen. `dispatch` sends consecutive ids from 0 with no
  929. // gaps — it advances its counter only after the write succeeds, so a call
  930. // rejected before reaching the wire consumes nothing — which makes the
  931. // next legitimate id exactly `nextCallId`.
  932. //
  933. // Retaining a set instead let a program write an unbounded run of unique
  934. // forged ids, each below the 256 MiB per-frame ceiling so nothing
  935. // rejected them, and grow host memory for the whole run. Accepting any
  936. // id above a high-water mark would have been just as wrong in the other
  937. // direction: one forged `{"id": 9999}` would starve every honest call
  938. // after it. The exact successor is the only test that both bounds the
  939. // retained state to one number and cannot be poisoned by a forgery.
  940. let nextCallId = 0
  941. const handleFrame = (message: ChildToHost): void => {
  942. /* v8 ignore next -- late frame after settlement; defensive against forged post-settlement traffic. */
  943. if (settled) return
  944. switch (message.type) {
  945. case 'boot-ack':
  946. return // Presently informational.
  947. case 'log':
  948. if (message.truncated === true) {
  949. // The CHILD ledger hit its cap. Its marker is the last log text
  950. // there will be, so record it and stop host capture at the same
  951. // point: admitting it as ordinary text left the host budget open,
  952. // so later direct `os.write(1, ...)` bytes were retained AFTER the
  953. // marker and a host-side exhaustion could append a second one.
  954. // Both ledgers are keyed to the same `maxLogBytes`, so one marker
  955. // describes the run.
  956. if (!logsTruncated) {
  957. logsTruncated = true
  958. // The host's OWN marker, never the frame's text. `truncated` is
  959. // attacker-reachable, so trusting the text let a program write
  960. // `{"type":"log","truncated":true,"text":<1 MiB>}` and land all
  961. // of it in `logs` under a 64-byte `maxLogBytes` — measured, the
  962. // whole megabyte was retained, bypassing `admit` and its
  963. // ceiling. Both ledgers key off the same `maxLogBytes`, so the
  964. // marker the host generates says the same thing the child's
  965. // would have.
  966. logs.push(logTruncationMarker(this.config.maxLogBytes))
  967. }
  968. return
  969. }
  970. admit(message.text)
  971. return
  972. case 'done': {
  973. if (message.error) {
  974. finish({ error: { kind: message.error.kind, message: capMessage(message.error.message, this.config.maxValueBytes) } })
  975. return
  976. }
  977. if (message.value === undefined) {
  978. finish({})
  979. return
  980. }
  981. // Re-enforce the completion budget and number losslessness
  982. // host-side: a forged done frame bypasses the Python-side
  983. // _done_with_value check, and validateChildFrame no longer scans
  984. // the value (an unbounded scan would push every member of a wide
  985. // forgery before any cap ran). checkDoneValue folds both jobs into
  986. // one bounded, iterative traversal — iterative because the seam's
  987. // CodeJsonValue has no depth limit and an honest deep-but-small
  988. // completion must cross intact rather than dying on stringify
  989. // recursion; bounded because it stops at the cap without
  990. // materializing the encoding, rejecting a forged value anywhere
  991. // below the 256 MiB frame ceiling before it forces host-side copies.
  992. // The seam forbids substituting a rendered/truncated value, so an
  993. // oversized value fails the run as output-limit and a non-lossless
  994. // number as invalid-output. The value is JSON-plain by construction
  995. // (it came from JSON.parse of the frame), the traversal's precondition.
  996. const check = checkDoneValue(message.value, this.config.maxValueBytes)
  997. if (!check.ok) {
  998. finish(check.reason === 'over-budget'
  999. ? { error: { kind: 'output-limit', message: `completion value exceeded ${this.config.maxValueBytes} bytes` } }
  1000. : { error: { kind: 'invalid-output', message: 'completion value contained a non-lossless number' } })
  1001. return
  1002. }
  1003. finish({ value: message.value as CodeJsonValue })
  1004. return
  1005. }
  1006. case 'call': {
  1007. if (message.id !== nextCallId) return
  1008. nextCallId += 1
  1009. const record = bindings.get(message.global)?.functions
  1010. const fn = record && Object.hasOwn(record, message.name) ? record[message.name] : undefined
  1011. if (typeof fn !== 'function') {
  1012. // `call.global` and `call.name` are attacker-controlled strings
  1013. // with no byte cap of their own — only the 256 MiB fd-3 frame
  1014. // ceiling — so each is sliced to `maxValueBytes` CODE UNITS
  1015. // BEFORE it reaches the template. Interpolating them whole would
  1016. // copy them into the message, `JSON.stringify` would copy the
  1017. // escaped form, `encodeJsonPlain` the frame, and the pipe write
  1018. // again: four full-size host allocations off one below-ceiling
  1019. // forgery, past every hostile-peer bound the log and done-error
  1020. // paths apply. Nothing past the first `maxValueBytes` code units
  1021. // of either field can survive the byte cap anyway, so the slices
  1022. // lose only text `capMessage` would drop, and that final cap
  1023. // gives this reply the same budget and marker as a forged done
  1024. // error.
  1025. const cap = this.config.maxValueBytes
  1026. const target = `${message.global.slice(0, cap)}.${message.name.slice(0, cap)}`
  1027. sendReply({ type: 'reply', id: message.id, ok: false, message: capMessage(`unknown binding ${JSON.stringify(target)}`, cap) })
  1028. return
  1029. }
  1030. void (async () => {
  1031. try {
  1032. const resolved = await fn(message.args)
  1033. // The seam requires a lossy resolution to REJECT descriptively,
  1034. // not silently coerce: a raw JSON.stringify would turn NaN/
  1035. // Infinity into null and drop undefined fields. Snapshot through
  1036. // the same lossless-JSON boundary the worker backend uses (also
  1037. // iterative, so a deeply nested value cannot overflow the stack).
  1038. const value = snapshotJsonValue(resolved)
  1039. if (value === undefined) {
  1040. sendReply({ type: 'reply', id: message.id, ok: false, message: 'binding resolution must be lossless JSON' })
  1041. return
  1042. }
  1043. sendReply({ type: 'reply', id: message.id, ok: true, value })
  1044. } catch (error: unknown) {
  1045. sendReply({ type: 'reply', id: message.id, ok: false, message: messageOf(error) })
  1046. }
  1047. })()
  1048. return
  1049. }
  1050. }
  1051. }
  1052. // Write one reply frame with the iterative encoder: a binding
  1053. // resolution has no seam-level depth or byte cap, so a deeply nested
  1054. // value must not die on JSON.stringify's recursion. The payload is
  1055. // JSON-plain by construction (snapshotJsonValue output, or literal
  1056. // strings/numbers), which is encodeJsonPlain's precondition. A closed
  1057. // pipe (child already gone) is swallowed since the close path settles
  1058. // the run.
  1059. const sendReply = (payload: ReplyMessage): void => {
  1060. /* v8 ignore next -- `settled` covers a race where the child exits between decision and write. */
  1061. if (settled) return
  1062. try {
  1063. proto.write(`${encodeJsonPlain(payload)}\n`)
  1064. } catch {
  1065. // Pipe closed under us (child exited). The close path finishes the run.
  1066. }
  1067. }
  1068. // Escalate SIGTERM → grace → SIGKILL on the entire process group. Idempotent
  1069. // via `killing`.
  1070. let killing = false
  1071. let graceTimer: NodeJS.Timeout | undefined
  1072. // A backstop for the one case `close` cannot cover: model code that starts
  1073. // a descendant with `os.setsid()`/`start_new_session=True` moves it into a
  1074. // fresh process group, so the SIGTERM/SIGKILL aimed at the child's group
  1075. // (`kill(-pid)`) never reaches it. If that orphan inherited stdout/stderr/
  1076. // fd 3 and outlives the run, those pipes stay open and `close` never fires
  1077. // — leaving run() (and a teardown awaiting `finished`) hung indefinitely.
  1078. // finish() arms this deadline; when it fires we detach our stream handles
  1079. // and settle on the already-decided result regardless of the orphan.
  1080. let closeDeadline: NodeJS.Timeout | undefined
  1081. const killGroup = (sig: NodeJS.Signals): void => {
  1082. try {
  1083. /* v8 ignore next -- undefined pid means spawn never produced a process; finish() short-circuits before reaching kill(). */
  1084. if (child.pid !== undefined) process.kill(-child.pid, sig)
  1085. } catch {
  1086. // ESRCH — the process already died. Nothing to do.
  1087. }
  1088. }
  1089. const kill = (): void => {
  1090. /* v8 ignore next -- kill() is idempotent; tests do not double-invoke it. */
  1091. if (killing) return
  1092. killing = true
  1093. killGroup('SIGTERM')
  1094. // Escalate to SIGKILL after the grace window. The timer is `unref`'d so a
  1095. // pending SIGKILL never keeps the host process alive on its own; the
  1096. // guarantee that a same-group survivor is actually reaped before the fiber
  1097. // goes quiescent is enforced by settle() awaiting the group's death (see
  1098. // there), NOT by this timer firing during host lifetime. A setsid-escaped
  1099. // orphan in a FRESH group is the different case `closeDeadline` in finish()
  1100. // covers, since `close` never fires there.
  1101. graceTimer = setTimeout(() => { killGroup('SIGKILL') }, this.config.graceMs)
  1102. graceTimer.unref()
  1103. }
  1104. // True once the group has no members left: a signal-0 probe to the whole
  1105. // group (`kill(-pid, 0)`) throws ESRCH when empty (EPERM would still mean a
  1106. // member exists). Only meaningful once a spawn produced a pid.
  1107. const groupEmpty = (): boolean => {
  1108. /* v8 ignore next -- pid is always defined once escalation runs; the guard narrows the type. */
  1109. if (child.pid === undefined) return true
  1110. try {
  1111. process.kill(-child.pid, 0)
  1112. return false
  1113. } catch (error: unknown) {
  1114. return (error as NodeJS.ErrnoException).code === 'ESRCH'
  1115. }
  1116. }
  1117. let finishResolve!: () => void
  1118. const finished = new Promise<void>((done) => { finishResolve = done })
  1119. let resolved = false
  1120. // The decided terminal result for a live child, recorded by finish() and
  1121. // read by the `close` handler that settles it once the pipes have drained.
  1122. let decided: Omit<CodeRunResult, 'logs'>
  1123. // The single settlement point: resolve run() with the decided result and
  1124. // mark the fiber quiescent. Idempotent — the first call wins, so a later
  1125. // `close` after done/timeout/abort is absorbed as a no-op.
  1126. const settle = (result: Omit<CodeRunResult, 'logs'>): void => {
  1127. if (resolved) return
  1128. resolved = true
  1129. if (closeDeadline !== undefined) clearTimeout(closeDeadline)
  1130. // The child has exited by now (settle runs on `close`, or on a spawn
  1131. // that produced no pid), so its staging directory is no longer read and
  1132. // this run's copy goes away with it. Removed SYNCHRONOUSLY, before
  1133. // `resolve` below: a fire-and-forget removal left the directory on disk
  1134. // when `run()` resolved, so a caller could not observe the "gone by
  1135. // settlement" contract at all. Two files cost nothing to unlink here.
  1136. try {
  1137. rmSync(bootstrapDir, { recursive: true, force: true })
  1138. } catch {
  1139. // Swallows only a failure to remove this run's staging directory —
  1140. // `force` already absorbs a missing one, so what remains is a
  1141. // filesystem-level refusal. The run's own outcome is already decided
  1142. // and must still be delivered, and teardown retries what stays
  1143. // tracked; the directory holds no secret, only a copy of two
  1144. // checked-in scripts.
  1145. }
  1146. resolve({ ...result, logs })
  1147. // Mark the fiber quiescent for THIS run: drop it from `live` and resolve
  1148. // `finished` (what teardown awaits). Deferred until the process group is
  1149. // actually empty — dropping from `live` before then would let a
  1150. // `dispose()` that races a just-resolved run() snapshot an empty `live`
  1151. // and return while a same-group survivor is still alive, making teardown's
  1152. // "no SAME-GROUP subprocess outlives the fiber" guarantee false for that
  1153. // window (a setsid escapee is the documented exception — see teardown's
  1154. // JSDoc). Keeping the run in `live` until the group is reaped is exactly
  1155. // what makes a concurrent teardown await it.
  1156. const finalize = (): void => {
  1157. this.live.delete(live)
  1158. finishResolve()
  1159. }
  1160. // `finished` is what teardown awaits to honor "no same-group subprocess
  1161. // outlives the fiber". When no escalation ran (normal completion, no
  1162. // kill) or the group is already empty, cancel the pending SIGKILL and
  1163. // finalize now. Clearing it is what bounds the PID-reuse hazard: an armed
  1164. // `kill(-pid)` left to fire up to graceMs later could hit a RECYCLED pgid
  1165. // once the kernel reused the leader's pid, SIGKILLing an unrelated group.
  1166. // So the timer stays armed only while a real survivor exists — a
  1167. // same-group descendant that ignored SIGTERM but released the pipes,
  1168. // still alive here because its `close` is what got us to settle. In that
  1169. // case withhold finalize and poll the group on REF'd timers (a
  1170. // short-lived host would otherwise exit before the unref'd SIGKILL fired,
  1171. // reparenting the survivor to init), clearing the timer the moment the
  1172. // group empties. The wait is bounded by `graceMs + CLOSE_REAP_MARGIN_MS`
  1173. // in the normal case; if the host event loop was blocked past both timers
  1174. // the deadline branch below sends SIGKILL itself and grants ONE more reap
  1175. // margin, so the outer bound is `graceMs + 2 * CLOSE_REAP_MARGIN_MS`.
  1176. if (!killing || groupEmpty()) {
  1177. if (graceTimer !== undefined) clearTimeout(graceTimer)
  1178. finalize()
  1179. return
  1180. }
  1181. const deadline = Date.now() + this.config.graceMs + CLOSE_REAP_MARGIN_MS
  1182. // Once the deadline forces us to send SIGKILL ourselves, allow one more
  1183. // reap window for the kernel to tear the group down before giving up:
  1184. // SIGKILL is asynchronous, so the group is not gone the instant it is
  1185. // sent. `finalize` only runs on a confirmed-empty group, except at this
  1186. // final hard bound where nothing more can be done.
  1187. let hardDeadline = 0
  1188. const pollGroup = (): void => {
  1189. if (groupEmpty()) {
  1190. // The group is gone; the grace SIGKILL is moot. Cancel it (it may not
  1191. // have fired yet) and finalize. graceTimer is always defined here:
  1192. // pollGroup runs only when `killing` is set, and kill() armed it.
  1193. clearTimeout(graceTimer)
  1194. finalize()
  1195. return
  1196. }
  1197. if (hardDeadline === 0 && Date.now() >= deadline) {
  1198. // Deadline reached with the group still non-empty. This is reachable
  1199. // when the host event loop was blocked past both timers: Node runs
  1200. // this poll before the grace SIGKILL timer, so that SIGKILL may never
  1201. // have fired. Send it HERE (idempotent if the timer already ran) and
  1202. // keep polling for the group to actually empty — finalizing on mere
  1203. // signal delivery would declare quiescence while the group is still
  1204. // dying. Bound the extra wait by one more reap margin.
  1205. killGroup('SIGKILL')
  1206. clearTimeout(graceTimer)
  1207. hardDeadline = Date.now() + CLOSE_REAP_MARGIN_MS
  1208. }
  1209. // Hard bound: the self-sent SIGKILL delivered but `groupEmpty()` still
  1210. // reports the group non-empty for a full extra reap margin. This is
  1211. // reachable, not a kernel quirk: a SIGKILL'd same-group survivor
  1212. // lingers as a ZOMBIE until its parent `wait()`s it, and in a
  1213. // container whose PID 1 does not reap orphans the survivor is
  1214. // reparented to init and never waited, so the signal-0 probe keeps
  1215. // succeeding — the same environment dependence the Agent Note's
  1216. // rejected "assert the reap with process.kill(pid, 0)" alternative
  1217. // documents. The ignore stays because that container cannot be built
  1218. // deterministically across CI platforms, not because the branch is
  1219. // unreachable; finalizing here bounds the wait so such a deployment
  1220. // still goes quiescent within `graceMs + 2 * CLOSE_REAP_MARGIN_MS`.
  1221. /* v8 ignore next 4 -- reachable only in a PID-1-doesn't-reap container (zombie survivor); not deterministically buildable. */
  1222. if (hardDeadline !== 0 && Date.now() >= hardDeadline) {
  1223. finalize()
  1224. return
  1225. }
  1226. setTimeout(pollGroup, GROUP_REAP_POLL_MS)
  1227. }
  1228. pollGroup()
  1229. }
  1230. const finish = (result: Omit<CodeRunResult, 'logs'>): void => {
  1231. if (settled) return
  1232. settled = true
  1233. decided = result
  1234. clearTimeout(wallTimer)
  1235. request.signal?.removeEventListener('abort', onAbort)
  1236. // A spawn failure (ENOENT, EACCES) never produced a pid, so there is no
  1237. // process to kill: settle now. Its `close` still fires later and reaches
  1238. // the idempotent settle() again as a no-op.
  1239. if (child.pid === undefined) {
  1240. settle(result)
  1241. return
  1242. }
  1243. // Live child: SIGTERM→grace→SIGKILL, then let `close` (below) settle the
  1244. // run so any `done` frame buffered on fd 3 is handled first and the
  1245. // process is fully reaped before the fiber goes quiescent.
  1246. kill()
  1247. // `close` awaits every stdio stream draining, which a setsid-escaped
  1248. // orphan holding our inherited pipes can prevent forever. Bound that
  1249. // wait: after SIGKILL has had the grace window plus a margin to reap the
  1250. // child itself, force settlement on the decided result. Flush any
  1251. // newline-free stray residual FIRST — a leader that wrote a diagnostic
  1252. // with `os.write(1, ...)` and exited leaves it buffered, and destroying
  1253. // the stream below drops it before an `end`/`close` flush could run, so
  1254. // the diagnostic would be lost from `logs`. Detaching the stream handles
  1255. // then lets `close` land as a no-op if it ever arrives, and stops the
  1256. // orphan's stray output from being accounted against a run that already
  1257. // finished. `unref` so the deadline never keeps the host process alive.
  1258. closeDeadline = setTimeout(() => {
  1259. flushStray(strayOut)
  1260. flushStray(strayErr)
  1261. proto.destroy()
  1262. child.stdout.destroy()
  1263. child.stderr.destroy()
  1264. settle(result)
  1265. }, this.config.graceMs + CLOSE_REAP_MARGIN_MS)
  1266. closeDeadline.unref()
  1267. }
  1268. child.on('error', (error: Error) => {
  1269. finish({ error: { kind: 'worker-exit', message: `python spawn error: ${error.message}` } })
  1270. })
  1271. // `close` (not `exit`) is the settlement trigger: it fires only after the
  1272. // process exits AND every stdio stream — including the fd-3 protocol pipe —
  1273. // has drained, so a `done` frame the child wrote just before exiting is
  1274. // always handled before we settle. macOS can deliver `exit` before that
  1275. // final fd-3 data; keying off `close` makes the ordering irrelevant.
  1276. child.on('close', (code: number | null, signal: NodeJS.Signals | null) => {
  1277. // If done/timeout/abort already decided the result, finish() is a no-op
  1278. // and `decided` holds it — a SIGXCPU that arrives after a decision does
  1279. // not override it. Otherwise the child closed before completing: a
  1280. // SIGXCPU close is the kernel's own CPU meter firing — the RLIMIT_CPU
  1281. // soft limit, or the bootstrap's post-settlement getrusage check
  1282. // re-delivering SIGXCPU when a program trapped the soft limit and
  1283. // returned inside the soft-to-hard gap. That kernel-authoritative
  1284. // signal is the ONLY basis for the timeout classification: wall time
  1285. // is not evidence of CPU burn (a sleeping child SIGKILLed by a cgroup
  1286. // OOM killer, an operator, or itself consumed none), so every other
  1287. // signal or code — including an unsolicited SIGKILL, even the
  1288. // hard-limit one — reports as an opaque worker exit.
  1289. //
  1290. // The message names `cpuSeconds` as the CONFIGURED ceiling, not "the
  1291. // budget that fired": the child clamps RLIMIT_CPU to the stricter of
  1292. // `cpuSeconds` and any inherited soft limit, so under a tighter inherited
  1293. // cap SIGXCPU arrives before `cpuSeconds` — the host cannot see the
  1294. // effective value, so it states the ceiling it set rather than a second
  1295. // count it cannot guarantee.
  1296. finish(signal === 'SIGXCPU'
  1297. ? { error: { kind: 'timeout', message: `CPU time exhausted (limit at most the configured ${this.config.cpuSeconds}s; a stricter inherited RLIMIT_CPU can fire sooner)` } }
  1298. : { error: { kind: 'worker-exit', message: `python exited (code=${String(code)}, signal=${String(signal)}) before completing` } })
  1299. settle(decided)
  1300. })
  1301. // Fd-3 and the stdout/stderr pipes emit `error` on early child death
  1302. // (ECONNRESET/EPIPE); swallow them so they do not become uncaught. The
  1303. // authoritative failure signal is `child.on('close')` above.
  1304. const silenceStreamError = (): void => {}
  1305. proto.on('error', silenceStreamError)
  1306. child.stdout.on('error', silenceStreamError)
  1307. child.stderr.on('error', silenceStreamError)
  1308. /* jscpd:ignore-start -- wall-timer/abort/live-run wiring deliberately parallels code-runtime-worker; see the constructor note. */
  1309. const wallTimer = setTimeout(() => {
  1310. finish({ error: { kind: 'timeout', message: `wall-clock ceiling reached (${this.config.maxWallMs}ms)` } })
  1311. }, this.config.maxWallMs)
  1312. const onAbort = (): void => {
  1313. finish({ error: { kind: 'abort', message: messageOf(request.signal?.reason) } })
  1314. }
  1315. request.signal?.addEventListener('abort', onAbort, { once: true })
  1316. const live: LiveRun = {
  1317. kill,
  1318. finished,
  1319. settle: (failure: CodeRunFailure) => { finish({ error: failure }) },
  1320. }
  1321. this.live.add(live)
  1322. /* jscpd:ignore-end */
  1323. // Send the boot frame once fd 3 is writable. This runs LAST in run()'s
  1324. // synchronous setup: its failure path calls finish(), which reads
  1325. // wallTimer/onAbort and (through settle) live, so those bindings must
  1326. // already be initialized — issuing the write earlier hit their
  1327. // temporal dead zone and threw a ReferenceError that rejected run()
  1328. // instead of resolving the worker-exit it constructs here.
  1329. const boot: BootMessage = {
  1330. type: 'boot',
  1331. cpuSeconds: this.config.cpuSeconds,
  1332. addressSpaceBytes: this.config.addressSpaceMb * 1024 * 1024,
  1333. maxLogBytes: this.config.maxLogBytes,
  1334. maxValueBytes: this.config.maxValueBytes,
  1335. namespaces: [...bindings].map(([global, namespace]) => ({
  1336. global,
  1337. names: Object.keys(namespace.functions),
  1338. ...namespace.errorClass ? { errorClass: namespace.errorClass } : {},
  1339. })),
  1340. }
  1341. try {
  1342. proto.write(`${JSON.stringify(boot)}\n`)
  1343. proto.write(`${JSON.stringify({ type: 'run', program: request.program })}\n`)
  1344. } catch (error: unknown) {
  1345. finish({ error: { kind: 'worker-exit', message: `failed to boot python subprocess: ${messageOf(error)}` } })
  1346. return
  1347. }
  1348. })
  1349. }
  1350. }
  1351. export default PythonCodeRuntime