kern-sandbox 0.2.44 → 0.2.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -91,10 +91,10 @@ cargo install --git https://github.com/getkern/kern getkern --locked
91
91
  kern needs a Linux kernel with unprivileged user namespaces + cgroup v2. On Windows it runs under WSL2.
92
92
  Node 18+.
93
93
 
94
- The first call on a machine that has never run it is the slow one: it pulls `python:3.12-slim` before
95
- it can start a box. Every call after that reads the cached image, and the `startup_failed` row below
96
- has the measured cost of that first read, which on a slow machine is large enough to trip a short
97
- `timeoutS`.
94
+ On a machine that has never run it, `open()` is the slow step: it pulls `python:3.12-slim` first, on
95
+ its own budget, so the download is not charged to any call's `timeoutS`. The first call after that
96
+ still reads the image cold, and the `startup_failed` row below has the measured cost of that first
97
+ read, which on a slow machine is large enough to trip a short `timeoutS`.
98
98
 
99
99
  **On a Mac this package installs but cannot run**, and it says so rather than sending you after a
100
100
  download that does not exist: kern is Linux-only, because macOS has no namespaces and no cgroups. Run
@@ -218,8 +218,9 @@ new Sandbox({
218
218
  workspace, // host dir to persist; omit for a temp dir deleted on close()
219
219
  workspaceMaxBytes, // default null; caps what the workspace ACCUMULATES across calls. Cooperative:
220
220
  // the call that exceeds it runs, the next is refused
221
- persist, // default false; true = ONE resident box per name, every call `kern exec`s into it
222
- // (2 ms against 6 ms). Needs name + workspace; survives close(), destroy() stops it
221
+ persist, // default false; true = ONE resident box per name, every call `kern exec`s into it.
222
+ // Keeps the box, not the interpreter. Needs name + workspace; survives close(),
223
+ // destroy() stops it
223
224
  name, // the stable identity two processes share a persist box by
224
225
  persistTtlS, // default 3600: the resident box ends by itself after this
225
226
  memoryMb, // default 512
Binary file
Binary file
package/index.d.ts CHANGED
@@ -99,8 +99,9 @@ export interface SandboxOptions {
99
99
  /** A stable identity, used only with `persist`: two processes that name the same sandbox meet the
100
100
  * same resident box. */
101
101
  name?: string | null;
102
- /** Keep ONE resident box and run every call in it with `kern exec`, 2 ms against 6 ms for a fresh
103
- * box. Requires `name` and `workspace`. Survives close(); destroy() stops it. A resident box is not
102
+ /** Keep ONE resident box and run every call in it with `kern exec`. It keeps the box, not the
103
+ * interpreter: each call still starts a fresh `python3`, so a call costs about as much as a fresh box
104
+ * (measured on one host: 11.4 ms against 13.9). Requires `name` and `workspace`. Survives close(); destroy() stops it. A resident box is not
104
105
  * a fresh one: /tmp accumulates and the PID namespace is shared. A box built under another posture
105
106
  * with the same name is refused. Default false. */
106
107
  persist?: boolean;
package/index.js CHANGED
@@ -42,7 +42,7 @@ const crypto = require("crypto");
42
42
  const zlib = require("zlib");
43
43
  const { spawn, spawnSync } = require("child_process");
44
44
 
45
- const VERSION = "0.2.44";
45
+ const VERSION = "0.2.45";
46
46
 
47
47
  const DEFAULT_IMAGE = "python:3.12-slim";
48
48
  // WHAT THE DEFAULT IMAGE CONTAINS, as a fact ABOUT THE IMAGE and not about its name. It drives the
@@ -393,6 +393,55 @@ function sanitizeRef(image) {
393
393
  return `${out}-${fnv1a(ref)}`;
394
394
  }
395
395
 
396
+ /** THE IMAGE IS FETCHED BEFORE THE FIRST BOX, ON ITS OWN BUDGET. Without this the first box of a
397
+ * session pulled it inside that call's deadline: measured on a Jetson with the 145 MB MCP image, the
398
+ * first cell of a fresh cache answered `startup_failed` at the 30 s default, the second finished the
399
+ * download in 18 s and only the third ran the cell. A deadline is for the CODE. 900 s is the most a
400
+ * download may take (145 MB at 160 KB/s); a refused connection fails in 10 ms, measured. Mirrors
401
+ * `_IMAGE_FETCH_BUDGET_S`. */
402
+ const IMAGE_FETCH_BUDGET_S = 900;
403
+ /** One download per image at a time in this process; a second caller waits for it, then looks again. */
404
+ const IMAGE_FETCHES = new Map();
405
+
406
+ /** True when kern has finished storing `image`: its `.ok` sentinel, which kern writes LAST. A local
407
+ * stat; "cannot tell" reads as not cached, which costs one `kern pull` that finds the image (2 ms). */
408
+ function imageIsCached(image) {
409
+ try {
410
+ return fs.existsSync(path.join(cacheHome(), "kern", "images", `${sanitizeRef(image)}.ok`));
411
+ } catch {
412
+ return false;
413
+ }
414
+ }
415
+
416
+ /** Make sure kern has `image` before any box needs it. Best effort, never rejects: a pull that FAILS
417
+ * changes nothing, because the box that follows pulls again and reports the failure in its own words.
418
+ * Asynchronous, so a download does not hold the event loop. Mirrors `_fetch_image`. */
419
+ function fetchImage(kernBin, image, budgetS = IMAGE_FETCH_BUDGET_S) {
420
+ if (imageIsCached(image)) return Promise.resolve();
421
+ const running = IMAGE_FETCHES.get(image);
422
+ if (running) return running.then(() => fetchImage(kernBin, image, budgetS));
423
+ const p = new Promise((resolve) => {
424
+ let child;
425
+ try {
426
+ // All three streams closed: a failure here is reported by the box that follows.
427
+ child = spawn(kernBin, ["pull", image], { stdio: "ignore" });
428
+ } catch {
429
+ resolve();
430
+ return;
431
+ }
432
+ const killer = setTimeout(() => child.kill("SIGKILL"), budgetS * 1000);
433
+ if (typeof killer.unref === "function") killer.unref();
434
+ const done = () => {
435
+ clearTimeout(killer);
436
+ resolve();
437
+ };
438
+ child.on("error", done);
439
+ child.on("close", done);
440
+ }).finally(() => IMAGE_FETCHES.delete(image));
441
+ IMAGE_FETCHES.set(image, p);
442
+ return p;
443
+ }
444
+
396
445
  /** The file, inside a published cache, naming the image kern had when the cache was built. */
397
446
  const PYC_SOURCE_ID = ".kern-source-id";
398
447
 
@@ -938,7 +987,7 @@ sys.exit(_rc)
938
987
  // {stdout, stderr, rc, results}. User prints go to a buffer, so the control channel stays clean. String.raw
939
988
  // keeps the single `\n` byte-literal intact (the driver has no backtick or ${...}). Byte-identical to the
940
989
  // Python binding's _PY_KERNEL_DRIVER so both bindings behave the same.
941
- const PY_KERNEL_DRIVER = String.raw`import sys, io, json, base64, builtins, ast, os, threading
990
+ const PY_KERNEL_DRIVER = String.raw`import sys, io, json, base64, builtins, ast, os, threading, codecs, select, time
942
991
  _g = {"__name__": "__main__"}
943
992
  _out = []
944
993
  def _bundle(o):
@@ -1009,31 +1058,10 @@ _CAP = __KERN_OUTCAP__
1009
1058
  _RESCAP = __KERN_RESCAP__
1010
1059
  _MARK = b"\x00\x01KRNCELLDONE\x01\x00" # per-cell barrier sentinel written to user fd 1/2 after exec
1011
1060
  _ulock = threading.Lock()
1012
- _ubuf = {1: bytearray(), 2: bytearray()}
1013
1061
  _mevt = {1: threading.Event(), 2: threading.Event()}
1014
- # Set by the drain threads when they cut a buffer at _CAP, read+reset by the cell loop under _ulock. A
1015
- # list (not a bare name) because the drainers rebind nothing: they mutate this one shared cell.
1062
+ # Set when this cell's output is cut at _CAP, read+reset by the cell loop under _ulock. A list (not a
1063
+ # bare name) because the writers rebind nothing: they mutate this one shared cell.
1016
1064
  _tcut = [False]
1017
- def _drain(fd, key):
1018
- while True:
1019
- try:
1020
- chunk = os.read(fd, 65536)
1021
- except OSError:
1022
- break
1023
- if not chunk:
1024
- break
1025
- with _ulock:
1026
- _b = _ubuf[key]
1027
- _b += chunk
1028
- _i = _b.find(_MARK)
1029
- if _i >= 0:
1030
- del _b[_i:_i + len(_MARK)] # strip the barrier sentinel; signal the cell it is drained
1031
- _mevt[key].set()
1032
- if len(_b) > _CAP:
1033
- del _b[_CAP:]
1034
- _tcut[0] = True
1035
- threading.Thread(target=_drain, args=(_u1r, 1), daemon=True).start()
1036
- threading.Thread(target=_drain, args=(_u2r, 2), daemon=True).start()
1037
1065
  _MAIN_PID = os.getpid() # a cell that raw os.fork()s copies this whole process; the child must NOT re-enter
1038
1066
  _rin = os.fdopen(_ctrl_in, "rb")
1039
1067
  def _read():
@@ -1048,11 +1076,167 @@ def _read():
1048
1076
  return None
1049
1077
  buf += chunk
1050
1078
  return buf.decode("utf-8")
1079
+ _wlock = threading.Lock()
1051
1080
  def _write(obj):
1052
1081
  b = json.dumps(obj).encode("utf-8")
1053
1082
  _data = memoryview(str(len(b)).encode() + b"\n" + b)
1054
- while _data:
1055
- _data = _data[os.write(_ctrl_out, _data):]
1083
+ with _wlock:
1084
+ while _data:
1085
+ _data = _data[os.write(_ctrl_out, _data):]
1086
+ # OUTPUT IS STREAMED, one frame per write, while the cell runs. It used to be collected here and sent in
1087
+ # the cell's reply, so a cell the sandbox KILLED (an OOM, a timeout) took everything it had printed with
1088
+ # it: no reply, so no output, and the caller could not tell "printed nothing" from "printed, then died".
1089
+ # {"o": text} is stdout and {"e": text} stderr; the reply that ends the cell carries the rest. A
1090
+ # frame holds at most _CHUNK characters, so no single frame nears the host's cap however much is printed.
1091
+ _KEY = {1: "o", 2: "e"}
1092
+ _CHUNK = 8192
1093
+ _sent = {1: 0, 2: 0} # characters streamed in the current cell, per stream, bounded by _CAP
1094
+ _live = [False] # True while a cell runs: output between cells belongs to no cell and is dropped
1095
+ def _emit(key, text):
1096
+ # Called with _ulock held.
1097
+ if not text or not _live[0]:
1098
+ return
1099
+ room = _CAP - _sent[key]
1100
+ if len(text) > room:
1101
+ text = text[:max(room, 0)]
1102
+ _tcut[0] = True
1103
+ if not text:
1104
+ return
1105
+ _sent[key] += len(text)
1106
+ for _p in range(0, len(text), _CHUNK):
1107
+ _write({_KEY[key]: text[_p:_p + _CHUNK]})
1108
+ # A print() is queued and sent within _FLUSH_S by one thread, woken once per burst; flush(), os._exit()
1109
+ # and the end of the cell send what is queued at once. So what a SIGKILLed cell loses is at most its last
1110
+ # millisecond of output, where it used to lose all of it. One frame per write made 10 000 prints cost
1111
+ # 58 ms against 1.8 ms when they were only collected (and 9.8 ms on the one-shot path).
1112
+ _FLUSH_S = 0.001
1113
+ _armed = [False] # the flusher is already due: a burst wakes it once, not once per write
1114
+ _pend = {1: [], 2: []}
1115
+ _pend_n = {1: 0, 2: 0}
1116
+ _flush_cv = threading.Condition(_ulock)
1117
+ def _flush_py(key):
1118
+ # Called with _ulock held.
1119
+ if _pend[key]:
1120
+ _t = "".join(_pend[key])
1121
+ _pend[key].clear()
1122
+ _pend_n[key] = 0
1123
+ _emit(key, _t)
1124
+ class _Stream(io.TextIOBase):
1125
+ # The cell's sys.stdout / sys.stderr. ORDER with fd output (a subprocess, C code) is kept by reading,
1126
+ # under the same lock, whatever already sits in the fd pipe before this text is queued: what was
1127
+ # written first goes out first. A forked child writes to the fd instead, which the parent drains: a
1128
+ # frame from the child would interleave with the parent's.
1129
+ def __init__(self, key):
1130
+ self._key = key
1131
+ def writable(self):
1132
+ return True
1133
+ @property
1134
+ def encoding(self):
1135
+ return "utf-8"
1136
+ def write(self, s):
1137
+ if not isinstance(s, str):
1138
+ raise TypeError("write() argument must be str, not " + type(s).__name__)
1139
+ if os.getpid() != _MAIN_PID:
1140
+ _b = memoryview(s.encode("utf-8", "replace"))
1141
+ while _b:
1142
+ _b = _b[os.write(self._key, _b):]
1143
+ return len(s)
1144
+ with _ulock:
1145
+ _pull(self._key)
1146
+ _pend[self._key].append(s)
1147
+ _pend_n[self._key] += len(s)
1148
+ if _pend_n[self._key] >= _CHUNK:
1149
+ _flush_py(self._key)
1150
+ elif not _armed[0]:
1151
+ _armed[0] = True
1152
+ _flush_cv.notify()
1153
+ return len(s)
1154
+ def flush(self):
1155
+ if os.getpid() == _MAIN_PID:
1156
+ with _ulock:
1157
+ _pull(self._key)
1158
+ _flush_py(self._key)
1159
+ def _flusher():
1160
+ while True:
1161
+ with _ulock:
1162
+ while not _armed[0]:
1163
+ _flush_cv.wait()
1164
+ time.sleep(_FLUSH_S)
1165
+ with _ulock:
1166
+ _armed[0] = False
1167
+ _flush_py(1)
1168
+ _flush_py(2)
1169
+ threading.Thread(target=_flusher, daemon=True).start()
1170
+ _real_os_exit = os._exit
1171
+ def _os_exit_sending(n):
1172
+ # os._exit ends the process without any of Python's cleanup, so what is queued goes out first. A
1173
+ # forked child has nothing queued here (it writes to the fd), and a lock held elsewhere is waited for
1174
+ # briefly, never forever: an exit must not hang.
1175
+ if os.getpid() == _MAIN_PID and _ulock.acquire(timeout=0.5):
1176
+ try:
1177
+ _pull(1)
1178
+ _pull(2)
1179
+ _flush_py(1)
1180
+ _flush_py(2)
1181
+ finally:
1182
+ _ulock.release()
1183
+ _real_os_exit(n)
1184
+ os._exit = _os_exit_sending
1185
+ def _mark_prefix_len(data):
1186
+ # How many bytes at the END of the data could be the START of the barrier: held back until the next
1187
+ # read says whether they are, so a barrier split across two reads is still found and never streamed.
1188
+ for _n in range(min(len(_MARK) - 1, len(data)), 0, -1):
1189
+ if _MARK.startswith(data[-_n:]):
1190
+ return _n
1191
+ return 0
1192
+ _UFD = {1: _u1r, 2: _u2r}
1193
+ os.set_blocking(_u1r, False)
1194
+ os.set_blocking(_u2r, False)
1195
+ # poll(0) answers "is there anything to read" without the BlockingIOError an empty non-blocking read
1196
+ # raises: 0.19 us against 0.5, measured, and every print() asks it once.
1197
+ _POLL = {1: select.poll(), 2: select.poll()}
1198
+ _POLL[1].register(_u1r, select.POLLIN)
1199
+ _POLL[2].register(_u2r, select.POLLIN)
1200
+ _dec = {1: codecs.getincrementaldecoder("utf-8")("replace"), 2: codecs.getincrementaldecoder("utf-8")("replace")}
1201
+ _held = {1: b"", 2: b""}
1202
+ def _pull(key):
1203
+ # Called with _ulock held: read everything the fd pipe holds right now and stream it. Every read of the
1204
+ # pipe happens under the lock and is sent before the lock is released, which is what keeps fd output
1205
+ # and print() in the order they were written. Returns False at EOF. At most 64 reads (4 MiB) a call:
1206
+ # a child writing without pause (yes(1)) would otherwise hold the lock, and with it every print().
1207
+ if not _POLL[key].poll(0):
1208
+ return True
1209
+ for _r in range(64):
1210
+ try:
1211
+ chunk = os.read(_UFD[key], 65536)
1212
+ except BlockingIOError:
1213
+ return True
1214
+ except OSError:
1215
+ return False
1216
+ if not chunk:
1217
+ return False
1218
+ _flush_py(key) # print() text queued before these bytes arrived was written before them
1219
+ data = _held[key] + chunk
1220
+ _i = data.find(_MARK)
1221
+ if _i >= 0:
1222
+ _emit(key, _dec[key].decode(data[:_i], final=True)) # all of the cell's bytes, THEN the barrier
1223
+ data = data[_i + len(_MARK):]
1224
+ _mevt[key].set()
1225
+ _n = _mark_prefix_len(data)
1226
+ _held[key] = data[len(data) - _n:] if _n else b""
1227
+ _emit(key, _dec[key].decode(data[:len(data) - _n]))
1228
+ return True
1229
+ def _drain(fd, key):
1230
+ while True:
1231
+ try:
1232
+ select.select([fd], [], [])
1233
+ except (OSError, ValueError):
1234
+ break
1235
+ with _ulock:
1236
+ if not _pull(key):
1237
+ break
1238
+ threading.Thread(target=_drain, args=(_u1r, 1), daemon=True).start()
1239
+ threading.Thread(target=_drain, args=(_u2r, 2), daemon=True).start()
1056
1240
  # Readiness. Popen returns when the FORK happens, not when kern has built the box and CPython has
1057
1241
  # booted inside it, so a pool that published a box on Popen alone would hand out boxes that are still
1058
1242
  # starting - and the caller would pay the remainder of that start on its own clock, which is the exact
@@ -1067,9 +1251,10 @@ while True:
1067
1251
  break
1068
1252
  _out.clear()
1069
1253
  with _ulock:
1070
- _m1, _m2 = len(_ubuf[1]), len(_ubuf[2])
1071
1254
  _tcut[0] = False # a cut belongs to the cell it happens in, so clear it at the cell boundary
1072
- _so, _se = io.StringIO(), io.StringIO()
1255
+ _sent[1] = _sent[2] = 0
1256
+ _live[0] = True
1257
+ _so, _se = _Stream(1), _Stream(2)
1073
1258
  _rc = 0
1074
1259
  _oo, _oe, _oi = sys.stdout, sys.stderr, sys.stdin
1075
1260
  sys.stdout, sys.stderr = _so, _se
@@ -1111,9 +1296,13 @@ while True:
1111
1296
  _out.append({"image/png": base64.b64encode(_b.getvalue()).decode()})
1112
1297
  except Exception:
1113
1298
  pass
1114
- # Barrier: write the sentinel to fd 1/2 and wait until the drainers have consumed up to it, so this
1115
- # cell's raw/subprocess output is FULLY captured (not racily missed) before we snapshot. The captured
1116
- # raw bytes are appended AFTER the precise in-order print() capture from the redirected sys.stdout.
1299
+ # Barrier: write the sentinel to fd 1/2 and read up to it, so this cell's raw/subprocess output is
1300
+ # FULLY captured (not racily missed) before the reply. It is read HERE, by this thread, right after
1301
+ # it is written: waiting for a drain thread to wake from select() for it doubled the cost of an empty
1302
+ # cell (0.04 -> 0.16 ms, measured).
1303
+ with _ulock:
1304
+ _flush_py(1)
1305
+ _flush_py(2)
1117
1306
  _mevt[1].clear()
1118
1307
  _mevt[2].clear()
1119
1308
  try:
@@ -1121,23 +1310,17 @@ while True:
1121
1310
  os.write(2, _MARK)
1122
1311
  except OSError:
1123
1312
  pass
1313
+ with _ulock:
1314
+ _pull(1)
1315
+ _pull(2)
1124
1316
  _mevt[1].wait(2.0)
1125
1317
  _mevt[2].wait(2.0)
1126
1318
  with _ulock:
1127
- _r1 = bytes(_ubuf[1][_m1:])
1128
- _r2 = bytes(_ubuf[2][_m2:])
1319
+ _live[0] = False
1129
1320
  _tr = _tcut[0]
1130
- # sys.stdout is a StringIO, so _CAP (which bounds only the raw-fd drain) never bounded a cell that
1131
- # printed through it: printing a gigabyte built the whole string into the reply. Cut BOTH streams at
1132
- # the same cap and say so, which is what the cold path's capped reader does.
1133
- _o1 = _so.getvalue() + _r1.decode("utf-8", "replace")
1134
- _o2 = _se.getvalue() + _r2.decode("utf-8", "replace")
1135
- if len(_o1) > _CAP:
1136
- _o1 = _o1[:_CAP]
1137
- _tr = True
1138
- if len(_o2) > _CAP:
1139
- _o2 = _o2[:_CAP]
1140
- _tr = True
1321
+ # Both streams went out as frames while the cell ran, each cut at _CAP by _emit, so a cell that
1322
+ # prints a gigabyte through sys.stdout streams _CAP characters and says so.
1323
+ _o1 = _o2 = ""
1141
1324
  # Results are bounded bundle by bundle rather than by serializing the whole list and measuring it: a
1142
1325
  # single json.dumps of an oversized list would build the entire payload in the box before anything
1143
1326
  # could reject it. A bundle that alone exceeds the budget is dropped, not truncated mid-JSON.
@@ -1418,6 +1601,25 @@ function sandboxFault(type, message) {
1418
1601
  /** Binaries already identified as kern, keyed by identity and not by path: a `kern` REPLACED between two
1419
1602
  * calls is a different program and gets checked again. */
1420
1603
  const VERIFIED_KERN = new Set();
1604
+ /** The `--version` line of each verified binary, by the same key: what the resident path needs to know
1605
+ * whether a missing started byte means anything (see `kernExecReportsItsStart`). */
1606
+ const KERN_VERSION_LINE = new Map();
1607
+
1608
+ /** The X.Y.Z in what `kern --version` printed, or null. Mirrors `_kern_release`. */
1609
+ function kernRelease(version) {
1610
+ const m = /(\d+)\.(\d+)\.(\d+)/.exec(version || "");
1611
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
1612
+ }
1613
+
1614
+ /** Whether this kern's `kern exec` writes the KERN_STARTED_FD bytes ONLY for a command that ran. v0.30.1
1615
+ * wrote them on refusals too (MEASURED: `[1, 0, 0, 0]` for an exec that refused, exit 126), so only from
1616
+ * v0.30.2 does a missing byte prove the call never started. Mirrors `_kern_exec_reports_its_start`. */
1617
+ function kernExecReportsItsStart(version) {
1618
+ const r = kernRelease(version);
1619
+ if (!r) return false;
1620
+ for (let i = 0; i < 3; i++) if (r[i] !== [0, 30, 2][i]) return r[i] > [0, 30, 2][i];
1621
+ return true;
1622
+ }
1421
1623
 
1422
1624
  /** Refuse a binary that does not IDENTIFY ITSELF as kern. Throws `SandboxError` if it does not.
1423
1625
  *
@@ -1442,7 +1644,7 @@ function verifyIsKern(bin) {
1442
1644
  } catch (e) {
1443
1645
  throw new SandboxError(`could not stat the kern binary at '${bin}': ${e.message}`);
1444
1646
  }
1445
- if (VERIFIED_KERN.has(key)) return;
1647
+ if (VERIFIED_KERN.has(key)) return KERN_VERSION_LINE.get(key) || "";
1446
1648
  const hint =
1447
1649
  "If this is not the kern you meant, set $KERN_BIN to the right path. To install kern:\n" +
1448
1650
  " curl -fsSL https://raw.githubusercontent.com/getkern/kern/main/install.sh | sh";
@@ -1464,6 +1666,8 @@ function verifyIsKern(bin) {
1464
1666
  );
1465
1667
  }
1466
1668
  VERIFIED_KERN.add(key);
1669
+ KERN_VERSION_LINE.set(key, first);
1670
+ return first;
1467
1671
  }
1468
1672
 
1469
1673
  /** The `kern` this package's npm tarball carries for this machine, or null.
@@ -2465,6 +2669,10 @@ class Sandbox {
2465
2669
  "resumed. new Sandbox({ name, persist: true, workspace })",
2466
2670
  );
2467
2671
  }
2672
+ // THE IMAGE BEFORE ANY BOX: the setup box, the resident box, the bytecode build and the warm pool
2673
+ // all start one, and each used to pull inside its own deadline. After the refusals above, so a
2674
+ // wrong configuration is not reported only after a download.
2675
+ await fetchImage(this._kern, this.image);
2468
2676
  if (this.setup) await this._runSetup(this.setup);
2469
2677
  // THE RESIDENT BOX IS CREATED AFTER THE SETUP, AND THAT ORDER IS THE FIX. It used to come first,
2470
2678
  // so `_runSetup` was routed into it (network silently dropped), and `_baseArgv` mounts
@@ -3035,7 +3243,8 @@ class Sandbox {
3035
3243
  // there dropped `network: true` in SILENCE, so `setup: "pip install X"` failed with a DNS error.
3036
3244
  // Same fix and same reason as the Python binding; `_enter` also creates the resident box after
3037
3245
  // the setup now, so in the normal path there is nothing to route into.
3038
- if (this._resident !== null && !isSetup) {
3246
+ const residentCall = this._resident !== null && !isSetup;
3247
+ if (residentCall) {
3039
3248
  // INTO THE RESIDENT BOX, which is the point of `persist`. `exec` and not `box`: measured, 2 ms
3040
3249
  // against 6 ms, and the box's own state is still there. `-w` puts the call in the same working
3041
3250
  // directory a fresh box starts in, so code writing a relative path lands in the workspace
@@ -3199,6 +3408,24 @@ class Sandbox {
3199
3408
  // that tells a genuine box-not-started apart from a workload that itself exited 125 (no marker ->
3200
3409
  // fault null -> a normal result). An older kern (127) is returned as a data fault, not thrown.
3201
3410
  // Runtime events where the code DID run (timeout, OOM, escape) stay as data on `.fault`.
3411
+ // A RESIDENT CALL THAT NEVER RAN: `kern exec` refused to enter the box (it could not put the
3412
+ // command under the box's caps; an ordinary ssh session is the common case). MEASURED: exit 126,
3413
+ // kern's explanation on stderr, and `fault: null`, so an agent read "your code exited 126".
3414
+ // Decided on kern's started byte, never on stderr, which the code writes too; that byte proves
3415
+ // it only from v0.30.2 (see `kernExecReportsItsStart`). A GONE box has its own repair. Mirrors
3416
+ // the Python binding.
3417
+ if (
3418
+ residentCall && !fault && !boxStarted && rc !== 0 &&
3419
+ !String(stderr || "").includes(RESIDENT_GONE) &&
3420
+ kernExecReportsItsStart(verifyIsKern(this._kern))
3421
+ ) {
3422
+ const said = String(stderr || "").trim().slice(0, 1200) || `exit ${rc}, nothing on stderr`;
3423
+ fault = sandboxFault(
3424
+ "startup_failed",
3425
+ "the call never ran: `kern exec` refused to enter the resident box, so the code did not " +
3426
+ `start. kern said: ${said}`,
3427
+ );
3428
+ }
3202
3429
  if (rc === 125 && fault && fault.type === "startup_failed") {
3203
3430
  return reject(new SandboxError(fault.message || "the box failed to start"));
3204
3431
  }
@@ -3876,7 +4103,11 @@ class Sandbox {
3876
4103
  `${WORKSPACE}/${resf}`,
3877
4104
  );
3878
4105
  await this.writeFile(runf, shim);
3879
- const result = await this._spawn(["python3", `${WORKSPACE}/${runf}`], {
4106
+ // `-u`: UNBUFFERED. CPython block-buffers stdout when it is a pipe, which it always is here, so a cell
4107
+ // that prints and is then SIGKILLed (OOM, timeout) or ends with `os._exit` lost whatever sat in that
4108
+ // buffer. The Python binding has passed it since 0.2.43 and this one did not: MEASURED, cold,
4109
+ // `print('BEFORE')` then a timeout returned "" here and "BEFORE" there. Mirrors the Python call.
4110
+ const result = await this._spawn(["python3", "-u", `${WORKSPACE}/${runf}`], {
3880
4111
  network: this.network,
3881
4112
  timeoutS: eff,
3882
4113
  onStdout,
@@ -3943,6 +4174,113 @@ const KERNEL_OVERSIZE = Symbol("kernel-oversize");
3943
4174
  // that must not drift from the shipped behaviour says which number it is and why.
3944
4175
  const KERNEL_DRAIN_CAP = 64 * 1024 * 1024;
3945
4176
 
4177
+ /** What a box the BINDING kills reports as its exit status: SIGKILL, in the shell's 128 + signal
4178
+ * convention the one-shot path already speaks. A resident kernel used to report -1 for a timeout, on the
4179
+ * stated ground that its interpreter survives the deadline; MEASURED, the timeout tears the box down.
4180
+ * Mirrors `_KILLED_BY_BINDING_RC`. */
4181
+ const KILLED_BY_BINDING_RC = 128 + 9;
4182
+
4183
+ /** The largest frame the host accepts from a driver: both streams, the results, and room for JSON.
4184
+ * Mirrors `_reply_frame_cap`. */
4185
+ function replyFrameCap(outCap) {
4186
+ return 2 * Math.trunc(outCap) + RESULTS_MAX + 65536;
4187
+ }
4188
+
4189
+ /** The output a driver streamed for ONE cell, kept up to `cap` characters per stream. The frames are
4190
+ * written inside the box, so past the cap nothing more is stored and the cut is recorded. Mirrors
4191
+ * `_CellOutput`. */
4192
+ class CellOutput {
4193
+ constructor(cap) {
4194
+ this._cap = Math.max(0, Math.trunc(cap));
4195
+ this._parts = { o: [], e: [] };
4196
+ this._n = { o: 0, e: 0 };
4197
+ this.truncated = false;
4198
+ }
4199
+
4200
+ add(obj) {
4201
+ for (const key of ["o", "e"]) {
4202
+ if (!(key in obj)) continue;
4203
+ let text = typeof obj[key] === "string" ? obj[key] : String(obj[key]);
4204
+ const room = this._cap - this._n[key];
4205
+ if (text.length > room) {
4206
+ text = text.slice(0, Math.max(room, 0));
4207
+ this.truncated = true;
4208
+ }
4209
+ if (text) {
4210
+ this._parts[key].push(text);
4211
+ this._n[key] += text.length;
4212
+ }
4213
+ }
4214
+ }
4215
+
4216
+ get stdout() {
4217
+ return this._parts.o.join("");
4218
+ }
4219
+
4220
+ get stderr() {
4221
+ return this._parts.e.join("");
4222
+ }
4223
+ }
4224
+
4225
+ /** How the (Python) driver writes an output frame: `json.dumps({"o": ...})`. Matched on the prefix so
4226
+ * the reply that ends a cell, which can carry a large figure, is parsed once. Mirrors
4227
+ * `_STREAM_FRAME_PREFIXES`. */
4228
+ const STREAM_FRAME_PREFIXES = ['{"o": ', '{"e": '];
4229
+
4230
+ /** Hand a parsed frame to whoever waits for one, or queue it. A frame used to be DROPPED when nobody was
4231
+ * waiting, which never happened while a cell sent exactly one; a streaming cell sends many, several to
4232
+ * one read. Shared by `Kernel` and `WarmBox`, which parse frames the same way. */
4233
+ function deliverFrame(self, body) {
4234
+ const w = self._waiters.shift();
4235
+ if (w) {
4236
+ clearTimeout(w.timer);
4237
+ w.resolve(body);
4238
+ } else {
4239
+ (self._frames ??= []).push(body);
4240
+ }
4241
+ }
4242
+
4243
+ /** The next frame, or how the channel ended (`null` / `KERNEL_OVERSIZE`) once every queued frame was
4244
+ * read, or `KERNEL_TIMEOUT`. Output queued before a death comes out before the death does. */
4245
+ function nextFrame(self, ms) {
4246
+ if (self._frames && self._frames.length) return Promise.resolve(self._frames.shift());
4247
+ if (self._end !== undefined || self._dead) return Promise.resolve(self._end === undefined ? null : self._end);
4248
+ return new Promise((resolve) => {
4249
+ const w = { resolve, timer: null };
4250
+ w.timer = setTimeout(() => {
4251
+ const i = self._waiters.indexOf(w);
4252
+ if (i >= 0) self._waiters.splice(i, 1);
4253
+ resolve(KERNEL_TIMEOUT);
4254
+ }, Math.max(0, ms));
4255
+ if (w.timer.unref) w.timer.unref();
4256
+ self._waiters.push(w);
4257
+ });
4258
+ }
4259
+
4260
+ /** Wait for the frame that ENDS a cell, folding every output frame before it into `out`. Mirrors
4261
+ * `_next_reply`: what arrived before a death or a timeout stays in `out`, which is the point. */
4262
+ async function nextReply(self, deadlineAtMs, out) {
4263
+ for (;;) {
4264
+ const left = deadlineAtMs - Date.now();
4265
+ if (left <= 0) return KERNEL_TIMEOUT;
4266
+ const frame = await nextFrame(self, left);
4267
+ if (typeof frame !== "string") return frame;
4268
+ if (STREAM_FRAME_PREFIXES.some((p) => frame.startsWith(p))) {
4269
+ let obj;
4270
+ try {
4271
+ obj = JSON.parse(frame);
4272
+ } catch {
4273
+ return frame;
4274
+ }
4275
+ if (obj && typeof obj === "object" && !Array.isArray(obj) && Object.keys(obj).length === 1) {
4276
+ out.add(obj);
4277
+ continue;
4278
+ }
4279
+ }
4280
+ return frame;
4281
+ }
4282
+ }
4283
+
3946
4284
  /** Materialize PY_KERNEL_DRIVER for one caller's output budget and handshake.
3947
4285
  *
3948
4286
  * The driver text is byte-identical to the Python binding's, so it carries the same three placeholders
@@ -4018,13 +4356,15 @@ class Kernel {
4018
4356
 
4019
4357
  async _open() {
4020
4358
  const sbx = this._sbx;
4021
- this._cap = sbx.maxOutputBytes;
4359
+ // The caller's output budget, the one the prewarmed box gets, and no results budget: the frame cap
4360
+ // bounds a reply. The budget used to be a fixed 64 MiB while the host refused any reply over
4361
+ // `maxOutputBytes`, so a cell printing more than that KILLED the kernel and its state. Output is
4362
+ // streamed now and cut on both sides. No readiness frame, which a persistent Kernel does not read.
4363
+ this._outCap = sbx.maxOutputBytes;
4364
+ this._cap = replyFrameCap(sbx.maxOutputBytes);
4022
4365
  const uid = crypto.randomBytes(4).toString("hex");
4023
4366
  this._driver = `.kernel-${uid}.py`;
4024
- // The historical constants, restated at the one call site that must not change: 64 MiB of raw drain
4025
- // and no results budget (the host's frame cap stays the only bound), and no readiness frame, which a
4026
- // persistent Kernel does not read and would consume as its first cell's reply.
4027
- await sbx.writeFile(this._driver, kernelDriver(KERNEL_DRAIN_CAP, 0, false));
4367
+ await sbx.writeFile(this._driver, kernelDriver(sbx.maxOutputBytes, 0, false));
4028
4368
  this._name = uniqueName();
4029
4369
  this._childEnv = { ...process.env };
4030
4370
  if (!sbx.enforceLimits) this._childEnv.KERN_NO_SCOPE = "1";
@@ -4093,17 +4433,14 @@ class Kernel {
4093
4433
  this._total = rest.length;
4094
4434
  this._need = -1;
4095
4435
  this._headerBytes = -1;
4096
- const w = this._waiters.shift();
4097
- if (w) {
4098
- clearTimeout(w.timer);
4099
- w.resolve(body);
4100
- }
4436
+ deliverFrame(this, body);
4101
4437
  }
4102
4438
  }
4103
4439
 
4104
4440
  _flush(val) {
4105
4441
  // A protocol error (oversize/malformed) marks the kernel dead: the stream is desynced, do not keep it.
4106
4442
  if (val === KERNEL_OVERSIZE || val === null) this._dead = true;
4443
+ if (this._end === undefined) this._end = val;
4107
4444
  while (this._waiters.length) {
4108
4445
  const w = this._waiters.shift();
4109
4446
  clearTimeout(w.timer);
@@ -4125,32 +4462,49 @@ class Kernel {
4125
4462
  const eff = timeoutS != null ? this._sbx._effTimeout(timeoutS) : this._timeout;
4126
4463
  const started = Date.now();
4127
4464
  const payload = Buffer.from(code, "utf8");
4128
- const reply = await new Promise((resolve) => {
4129
- const timer = setTimeout(() => {
4130
- const i = this._waiters.findIndex((w) => w.timer === timer);
4131
- if (i >= 0) this._waiters.splice(i, 1);
4132
- resolve(KERNEL_TIMEOUT);
4133
- }, eff * 1000);
4134
- this._waiters.push({ resolve, timer });
4135
- try {
4136
- this._child.stdin.write(`${payload.length}\n`);
4137
- this._child.stdin.write(payload);
4138
- } catch {
4139
- const i = this._waiters.findIndex((w) => w.timer === timer);
4140
- if (i >= 0) this._waiters.splice(i, 1);
4141
- clearTimeout(timer);
4142
- resolve(null);
4143
- }
4144
- });
4145
- if (reply === KERNEL_TIMEOUT) return this._teardownResult("timeout", `cell exceeded ${eff}s`, started);
4146
- if (reply === KERNEL_OVERSIZE)
4147
- return this._teardownResult("killed", `the kernel reply exceeded the ${this._cap}-byte cap`, started);
4148
- if (reply === null) {
4149
- const err = this._stderr.toString("utf8");
4150
- const [kind, dflt, rc] = this._kernelDeathFault(err, ...(await this._readCapSignal()));
4151
- return this._teardownResult(kind, err.trim() || dflt, started, rc);
4465
+ const out = new CellOutput(this._outCap);
4466
+ try {
4467
+ this._child.stdin.write(`${payload.length}\n`);
4468
+ this._child.stdin.write(payload);
4469
+ } catch {
4470
+ return this._deathResult(started, out);
4152
4471
  }
4153
- return this._resultFromReply(reply, started);
4472
+ const reply = await nextReply(this, started + eff * 1000, out);
4473
+ if (reply === KERNEL_TIMEOUT)
4474
+ return this._teardownResult("timeout", `cell exceeded ${eff}s`, started, KILLED_BY_BINDING_RC, out);
4475
+ if (reply === KERNEL_OVERSIZE)
4476
+ return this._teardownResult(
4477
+ "killed", `the kernel sent a frame larger than the ${this._cap}-byte cap`, started, KILLED_BY_BINDING_RC, out,
4478
+ );
4479
+ if (reply === null) return this._deathResult(started, out);
4480
+ return this._resultFromReply(reply, started, out);
4481
+ }
4482
+
4483
+ /** The box went away mid-cell: classify why from what kern wrote, keeping what the cell printed.
4484
+ * Mirrors `_death_result`. */
4485
+ async _deathResult(started, out) {
4486
+ const err = this._stderr.toString("utf8");
4487
+ const [capSignal, oomSignal, wrote, workloadSignal] = await this._readCapSignal();
4488
+ const [kind, dflt, rc] = this._kernelDeathFault(
4489
+ err, capSignal, oomSignal, wrote, workloadSignal, this._exitStatus(),
4490
+ );
4491
+ if (kind === null && workloadSignal === 0)
4492
+ // The cell ended the interpreter itself (`os._exit(N)`): its exit code, its own output, and no
4493
+ // sentence from us in its stderr.
4494
+ return this._teardownResult(null, "", started, rc, out, `the cell ended the interpreter with exit status ${rc}`);
4495
+ return this._teardownResult(kind, err.trim() || dflt, started, rc, out);
4496
+ }
4497
+
4498
+ /** The box process's own exit code once it has gone, or null while it is still there. */
4499
+ _exitStatus() {
4500
+ const c = this._child;
4501
+ return c && typeof c.exitCode === "number" && c.exitCode >= 0 ? c.exitCode : null;
4502
+ }
4503
+
4504
+ /** True once this kernel's interpreter is gone, whatever ended it: a fault, a cell that exited the
4505
+ * interpreter, or `close()`. Its in-memory state went with it. Mirrors `Kernel.ended`. */
4506
+ get ended() {
4507
+ return this._dead;
4154
4508
  }
4155
4509
 
4156
4510
  /** Turn one kernel reply into an `ExecutionResult`.
@@ -4158,15 +4512,15 @@ class Kernel {
4158
4512
  * Extracted so the UNTRUSTED-INPUT boundary is one named place a test can drive directly: `reply`
4159
4513
  * is JSON written INSIDE the box, by the same code the sandbox exists to contain. Every field is
4160
4514
  * attacker-chosen, and the question for each is what a missing or wrong-typed value must mean. */
4161
- _resultFromReply(reply, started) {
4515
+ _resultFromReply(reply, started, out = null) {
4162
4516
  let obj;
4163
4517
  try {
4164
4518
  obj = JSON.parse(reply);
4165
4519
  } catch {
4166
- return this._teardownResult("killed", "the kernel sent a malformed reply", started);
4520
+ return this._teardownResult("killed", "the kernel sent a malformed reply", started, KILLED_BY_BINDING_RC, out);
4167
4521
  }
4168
4522
  if (!obj || typeof obj !== "object")
4169
- return this._teardownResult("killed", "the kernel sent a non-object reply", started);
4523
+ return this._teardownResult("killed", "the kernel sent a non-object reply", started, KILLED_BY_BINDING_RC, out);
4170
4524
  // `rc` is the ONE field whose absence cannot be defaulted. `success` is
4171
4525
  // `exitCode === 0 && fault === null`, so coercing a missing or non-integer `rc` to 0 - which is
4172
4526
  // what this did - reported a SUCCESSFUL run. Since the JSON comes from the box, a cell could
@@ -4175,7 +4529,7 @@ class Kernel {
4175
4529
  // `"rc"`, and it is handled like the malformed replies above. `Number.isInteger` also rejects a
4176
4530
  // boolean, a float and a numeric string, which is what it is here for.
4177
4531
  if (!Number.isInteger(obj.rc))
4178
- return this._teardownResult("killed", "the kernel reply carried no usable exit code", started);
4532
+ return this._teardownResult("killed", "the kernel reply carried no usable exit code", started, KILLED_BY_BINDING_RC, out);
4179
4533
  // The REMAINING fields are informational, so a wrong type degrades to an empty value rather than
4180
4534
  // failing the call: coerced so a caller doing `r.stdout.trim()` cannot be crashed by a box that
4181
4535
  // sent a number.
@@ -4183,13 +4537,14 @@ class Kernel {
4183
4537
  ? obj.results.filter((r) => r && typeof r === "object").map((r) => new Result(r))
4184
4538
  : [];
4185
4539
  return new ExecutionResult({
4186
- stdout: typeof obj.stdout === "string" ? obj.stdout : "",
4187
- stderr: typeof obj.stderr === "string" ? obj.stderr : "",
4540
+ stdout: (out ? out.stdout : "") + (typeof obj.stdout === "string" ? obj.stdout : ""),
4541
+ stderr: (out ? out.stderr : "") + (typeof obj.stderr === "string" ? obj.stderr : ""),
4188
4542
  exitCode: obj.rc,
4189
4543
  durationMs: Date.now() - started,
4190
4544
  fault: null,
4191
4545
  files: [],
4192
- truncated: false,
4546
+ // The driver's own cut and the host's: a cell over its budget is told so, as on the cold path.
4547
+ truncated: obj.trunc === true || !!(out && out.truncated),
4193
4548
  results,
4194
4549
  });
4195
4550
  }
@@ -4207,7 +4562,7 @@ class Kernel {
4207
4562
  * `capSignal` is kern's unforgeable enforcement byte (0 = old kern / undetermined, 1 = cap enforced, 2 =
4208
4563
  * requested but NOT enforced). It no longer decides the TYPE, and a 2 still earns a sentence, because
4209
4564
  * "your cap was not in force here" is the one thing the caller cannot find out for itself. */
4210
- _kernelDeathFault(err, capSignal = 0, oomSignal = null, kernWrotePayload = false, workloadSignal = null) {
4565
+ _kernelDeathFault(err, capSignal = 0, oomSignal = null, kernWrotePayload = false, workloadSignal = null, exitStatus = null) {
4211
4566
  // THE EXIT CODE COMES FROM THE FOURTH BYTE, so both paths report one event the same way: this used to
4212
4567
  // be a flat -1 while the one-shot path said 137 for a kill, 159 for a blocked escape, 139 for a
4213
4568
  // segfault. -1 stays for the cases where no signal is known. Mirrors `_kernel_death_fault`.
@@ -4248,6 +4603,10 @@ class Kernel {
4248
4603
  // byte is set; this path had no such guard, so the widened predicate gets it here.
4249
4604
  if (!kernWrotePayload && looksLikeStartupFailure(err))
4250
4605
  return ["startup_failed", "the kernel box failed to start", rc];
4606
+ // NO SIGNAL AND A REAL EXIT STATUS: the cell ended the interpreter itself, `os._exit(N)` being the
4607
+ // ordinary way. The one-shot path reports that as exitCode N and no fault; this one said `killed`.
4608
+ // Mirrors the Python branch, which is placed after the cap branch there and reaches the same answer.
4609
+ if (workloadSignal === 0 && exitStatus !== null) return [null, "", exitStatus];
4251
4610
  if (capSignal === 2)
4252
4611
  return [
4253
4612
  "killed",
@@ -4299,33 +4658,40 @@ class Kernel {
4299
4658
  return [capSignal, oomSignal, boxStarted, workloadSignal];
4300
4659
  }
4301
4660
 
4302
- _teardownResult(type, message, started, exitCode = -1) {
4303
- this._death = type === null ? "the code crashed" : type;
4661
+ _teardownResult(type, message, started, exitCode = -1, out = null, death = null) {
4662
+ this._death = death || (type === null ? "the code crashed" : type);
4304
4663
  this._kill();
4664
+ const so = out ? out.stdout : "";
4665
+ const se = out ? out.stderr : "";
4666
+ const cut = !!(out && out.truncated);
4305
4667
  // Same rule as the one-shot path: a box that never STARTED (the kernel failed to boot) throws, it
4306
4668
  // does not return a hollow result. timeout/killed stay as data on the returned result.
4307
4669
  if (type === "startup_failed") throw new SandboxError(message || "the box failed to start");
4308
4670
  // `type === null` is a real answer, not a missing one: the code CRASHED and the sandbox did not act,
4309
4671
  // which is what the one-shot path reports for the same event. The message still travels on stderr.
4310
- if (type === null)
4672
+ if (type === null) {
4673
+ const sep = se && message && !se.endsWith("\n") ? "\n" : "";
4311
4674
  return new ExecutionResult({
4312
- stdout: "",
4313
- stderr: message,
4675
+ stdout: so,
4676
+ stderr: se + sep + message,
4314
4677
  exitCode,
4315
4678
  durationMs: Date.now() - started,
4316
4679
  fault: null,
4317
4680
  files: [],
4318
- truncated: false,
4681
+ truncated: cut,
4319
4682
  results: [],
4320
4683
  });
4684
+ }
4685
+ // WHAT THE CELL PRINTED BEFORE IT DIED: it used to be "" on every fault, because the output travelled
4686
+ // in the reply a killed cell never sends.
4321
4687
  return new ExecutionResult({
4322
- stdout: "",
4323
- stderr: "",
4688
+ stdout: so,
4689
+ stderr: se,
4324
4690
  exitCode,
4325
4691
  durationMs: Date.now() - started,
4326
4692
  fault: sandboxFault(type, message),
4327
4693
  files: [],
4328
- truncated: false,
4694
+ truncated: cut,
4329
4695
  results: [],
4330
4696
  });
4331
4697
  }
@@ -4575,16 +4941,13 @@ class WarmBox {
4575
4941
  this._total = rest.length;
4576
4942
  this._need = -1;
4577
4943
  this._headerBytes = -1;
4578
- const w = this._waiters.shift();
4579
- if (w) {
4580
- clearTimeout(w.timer);
4581
- w.resolve(body);
4582
- }
4944
+ deliverFrame(this, body);
4583
4945
  }
4584
4946
  }
4585
4947
 
4586
4948
  _flush(val) {
4587
4949
  if (val === KERNEL_OVERSIZE || val === null) this._dead = true;
4950
+ if (this._end === undefined) this._end = val;
4588
4951
  while (this._waiters.length) {
4589
4952
  const w = this._waiters.shift();
4590
4953
  clearTimeout(w.timer);
@@ -4593,17 +4956,7 @@ class WarmBox {
4593
4956
  }
4594
4957
 
4595
4958
  _await(ms) {
4596
- if (this._dead) return Promise.resolve(null);
4597
- return new Promise((resolve) => {
4598
- const w = { resolve, timer: null };
4599
- w.timer = setTimeout(() => {
4600
- const i = this._waiters.indexOf(w);
4601
- if (i >= 0) this._waiters.splice(i, 1);
4602
- resolve(KERNEL_TIMEOUT);
4603
- }, ms);
4604
- if (w.timer.unref) w.timer.unref();
4605
- this._waiters.push(w);
4606
- });
4959
+ return nextFrame(this, ms);
4607
4960
  }
4608
4961
 
4609
4962
  // -- the one cell ----------------------------------------------------------------------------------
@@ -4616,22 +4969,23 @@ class WarmBox {
4616
4969
  if (this._child === null) throw new SandboxError("prewarmed box was never started");
4617
4970
  const started = Date.now();
4618
4971
  const payload = Buffer.from(code, "utf8");
4972
+ const out = new CellOutput(this._sbx.maxOutputBytes);
4619
4973
  let body;
4620
4974
  try {
4621
4975
  this._child.stdin.write(`${payload.length}\n`);
4622
4976
  this._child.stdin.write(payload);
4623
- body = await this._await(deadlineS * 1000);
4977
+ body = await nextReply(this, started + deadlineS * 1000, out);
4624
4978
  } catch {
4625
- return this._faultResult("died", started, before);
4979
+ return this._faultResult("died", started, before, undefined, out);
4626
4980
  }
4627
4981
  if (body === KERNEL_TIMEOUT)
4628
- return this._faultResult("timeout", started, before, `code exceeded ${deadlineS}s`);
4982
+ return this._faultResult("timeout", started, before, `code exceeded ${deadlineS}s`, out);
4629
4983
  // Every branch below that rejects the reply produces the same shape, so it is written once. The
4630
4984
  // repetition was three copies of the same call differing only in a string, which is the form where
4631
4985
  // one copy quietly drifts from the others.
4632
4986
  const rejected = (message, truncated = false) =>
4633
- this._result("", "", this._exitCode(), started, before, {
4634
- truncated,
4987
+ this._result(out.stdout, out.stderr, this._exitCode(), started, before, {
4988
+ truncated: truncated || out.truncated,
4635
4989
  fault: { type: "killed", message },
4636
4990
  });
4637
4991
  if (body === KERNEL_OVERSIZE) {
@@ -4642,7 +4996,7 @@ class WarmBox {
4642
4996
  true,
4643
4997
  );
4644
4998
  }
4645
- if (body === null) return this._faultResult("died", started, before);
4999
+ if (body === null) return this._faultResult("died", started, before, undefined, out);
4646
5000
  this.retire();
4647
5001
  let obj = null;
4648
5002
  try {
@@ -4659,21 +5013,22 @@ class WarmBox {
4659
5013
  const results = Array.isArray(obj.results)
4660
5014
  ? obj.results.filter((r) => r && typeof r === "object").map((r) => new Result(r))
4661
5015
  : [];
4662
- return this._result(String(obj.stdout ?? ""), String(obj.stderr ?? ""), obj.rc, started, before, {
4663
- truncated: !!obj.trunc,
5016
+ return this._result(out.stdout + String(obj.stdout ?? ""), out.stderr + String(obj.stderr ?? ""), obj.rc, started, before, {
5017
+ truncated: !!obj.trunc || out.truncated,
4664
5018
  results,
4665
5019
  });
4666
5020
  }
4667
5021
 
4668
- _faultResult(kind, started, before, msg) {
5022
+ _faultResult(kind, started, before, msg, out = new CellOutput(0)) {
4669
5023
  const err = this._stderr.toString("utf8");
4670
5024
  if (kind === "timeout") {
4671
5025
  this.retire();
4672
- return this._result("", "", this._exitCode(), started, before, {
5026
+ return this._result(out.stdout, out.stderr, this._exitCode(), started, before, {
5027
+ truncated: out.truncated,
4673
5028
  fault: { type: "timeout", message: msg || "the code exceeded its deadline" },
4674
5029
  });
4675
5030
  }
4676
- const { boxStarted, capSignal, oomSignal } = parseStartedBytes(this._startedSig);
5031
+ const { boxStarted, capSignal, oomSignal, workloadSignal } = parseStartedBytes(this._startedSig);
4677
5032
  this.retire();
4678
5033
  let type = "killed";
4679
5034
  let dflt = "the box exited before the code finished";
@@ -4688,6 +5043,10 @@ class WarmBox {
4688
5043
  // for a box that existed, so with the byte set this THROW would be a cell's own column-0 line
4689
5044
  // deciding that the box never came up.
4690
5045
  throw new SandboxError(err.trim() || "the box failed to start");
5046
+ } else if (boxStarted && workloadSignal === 0) {
5047
+ // NO SIGNAL: the cell ended the interpreter itself (`os._exit(N)`). The cold path reports that as
5048
+ // exitCode N and no fault; this path said `killed`. Mirrors the Python warm path.
5049
+ return this._result(out.stdout, out.stderr, this._exitCode(), started, before, { truncated: out.truncated });
4691
5050
  } else if (capSignal === 2) {
4692
5051
  dflt =
4693
5052
  "the box was killed, and its memory cap was not enforced here (no cgroup delegation), " +
@@ -4697,7 +5056,8 @@ class WarmBox {
4697
5056
  "the box was killed and the kernel reported no OOM against its memory cap: an external kill " +
4698
5057
  "(`kern stop`, a signal, or the host running out of memory), not the box exceeding its own memory";
4699
5058
  }
4700
- return this._result("", "", this._exitCode(), started, before, {
5059
+ return this._result(out.stdout, out.stderr, this._exitCode(), started, before, {
5060
+ truncated: out.truncated,
4701
5061
  fault: { type, message: err.trim() || dflt },
4702
5062
  });
4703
5063
  }
@@ -4986,6 +5346,15 @@ module.exports = {
4986
5346
  _PYC_MOUNT: PYC_MOUNT,
4987
5347
  _PYC_SOURCE_ID: PYC_SOURCE_ID,
4988
5348
  _sanitizeRef: sanitizeRef,
5349
+ _imageIsCached: imageIsCached,
5350
+ _fetchImage: fetchImage,
5351
+ // The streaming protocol's pieces, for tests that drive the real driver without a box.
5352
+ _kernelDriver: kernelDriver,
5353
+ _WarmBox: WarmBox,
5354
+ _CellOutput: CellOutput,
5355
+ _nextReply: nextReply,
5356
+ _replyFrameCap: replyFrameCap,
5357
+ _kernExecReportsItsStart: kernExecReportsItsStart,
4989
5358
  _SANITIZE_VECTORS: SANITIZE_VECTORS,
4990
5359
  _pycSourceId: pycSourceId,
4991
5360
  // Exported for the test that proves a stale lock is swept: the marks decide what the sweep
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kern-sandbox",
3
- "version": "0.2.44",
3
+ "version": "0.2.45",
4
4
  "description": "Your model writes the code. This runs it where it can't touch your machine: a rootless Linux container, no daemon, no VM, no cloud, no account. A kernel boundary, not a microVM: for deliberately hostile code, use one.",
5
5
  "keywords": [
6
6
  "sandbox",