@code-yeongyu/senpi-codemode 2026.9.28 → 2026.9.29-3
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/CHANGELOG.md +123 -0
- package/README.md +13 -1
- package/package.json +4 -4
- package/src/bridge/memory-protocol.ts +39 -0
- package/src/bridge/protocol.ts +18 -0
- package/src/bridge/reserved.ts +2 -0
- package/src/config/memory-settings.ts +149 -0
- package/src/config/settings.ts +8 -2
- package/src/extension/session-manager-proxy.ts +6 -0
- package/src/extension/session-manager.ts +27 -25
- package/src/host-sdk.ts +1 -1
- package/src/index.ts +4 -0
- package/src/kernels/jl/kernel.ts +4 -1
- package/src/kernels/js/context-manager.ts +43 -5
- package/src/kernels/js/inline-worker.ts +2 -1
- package/src/kernels/js/interrupt-bounds.ts +8 -2
- package/src/kernels/js/kernel-contract.ts +13 -0
- package/src/kernels/js/local-module-loader.ts +2 -0
- package/src/kernels/js/webview-host.ts +59 -0
- package/src/kernels/js/worker-core.js +32 -3
- package/src/kernels/js/worker-global-sizes.js +123 -0
- package/src/kernels/js/worker-heap.js +59 -0
- package/src/kernels/js/worker-host.ts +2 -1
- package/src/kernels/js/worker-memory.js +94 -0
- package/src/kernels/js/worker-runtime.js +7 -2
- package/src/kernels/js/worker-slot.ts +16 -5
- package/src/kernels/js/worker-startup.ts +1 -0
- package/src/kernels/js/worker-webview-client.js +79 -0
- package/src/kernels/js/worker-webview.js +204 -0
- package/src/kernels/py/kernel-contract.ts +3 -0
- package/src/kernels/py/kernel.ts +15 -2
- package/src/kernels/py/prelude.py +327 -11
- package/src/kernels/py/transport.ts +4 -1
- package/src/kernels/rb/kernel.ts +4 -1
- package/src/kernels/shared/kernel-memory-host.ts +63 -0
- package/src/kernels/shared/kernel-memory.ts +136 -0
- package/src/kernels/shared/subprocess-contract.ts +10 -1
- package/src/kernels/shared/subprocess-kernel.ts +23 -2
- package/src/kernels/shared/subprocess-queue.ts +2 -0
- package/src/output/streaming-output-buffer.ts +229 -12
- package/src/output/streaming-output.ts +2 -2
- package/src/prompt/eval-prompt.ts +1 -1
- package/src/skill/bun-1-4/references/runtime-apis.md +1 -0
- package/src/tool/cell-runtime.ts +52 -10
- package/src/tool/detached-cell-contract.ts +4 -0
- package/src/tool/detached-cell-manager.ts +16 -1
- package/src/tool/detached-cell-notification.ts +10 -1
- package/src/tool/image.ts +6 -2
- package/src/tool/run-eval-cell.ts +19 -17
- package/src/tool/settled-image-spill.ts +108 -0
- package/src/tool/terminal-snapshot-store.ts +82 -10
- package/src/tool/types.ts +10 -0
|
@@ -6,6 +6,7 @@ import asyncio # noqa: ANYIO_OK — stdlib-only embedded kernel runner.
|
|
|
6
6
|
import base64
|
|
7
7
|
import codecs
|
|
8
8
|
import contextlib
|
|
9
|
+
import gc
|
|
9
10
|
import inspect
|
|
10
11
|
import io
|
|
11
12
|
import json
|
|
@@ -17,6 +18,7 @@ import subprocess
|
|
|
17
18
|
import sys
|
|
18
19
|
import time
|
|
19
20
|
import traceback
|
|
21
|
+
import types
|
|
20
22
|
import urllib.error
|
|
21
23
|
import urllib.request
|
|
22
24
|
import uuid
|
|
@@ -987,6 +989,313 @@ USER_NS.update(
|
|
|
987
989
|
}
|
|
988
990
|
)
|
|
989
991
|
|
|
992
|
+
# Kernel memory (mirrors src/kernels/js/worker-memory.js; thresholds arrive on `init`, the host applies the
|
|
993
|
+
# notice/ceiling policy in src/kernels/shared/kernel-memory.ts). Names present right after prelude install
|
|
994
|
+
# are never reported as user globals.
|
|
995
|
+
_MEMORY_BASELINE = frozenset(USER_NS)
|
|
996
|
+
_MIB = 1024 * 1024
|
|
997
|
+
_MEMORY_MIN_GROWTH = 64 * _MIB
|
|
998
|
+
_MEMORY_GROWTH_RATIO = 0.25
|
|
999
|
+
_MEMORY_NOTICE_GROWTH_RATIO = 1.25
|
|
1000
|
+
# A collection costs at most 1/20 of the time since the previous one.
|
|
1001
|
+
_MEMORY_COLLECT_RATE_FLOOR = 20
|
|
1002
|
+
_MEMORY_REPORTED_GLOBALS = 5
|
|
1003
|
+
_MEMORY_MIN_REPORTED_BYTES = _MIB
|
|
1004
|
+
_SIZER_SAMPLED = 1_000
|
|
1005
|
+
_SIZER_NODE_BUDGET = 200_000
|
|
1006
|
+
_SIZER_MAX_DEPTH = 64
|
|
1007
|
+
_SIZER_POINTER = 8
|
|
1008
|
+
_SIZER_LEAF_TYPES = (str, bytes, bytearray, int, float, complex, bool, type(None), range, memoryview)
|
|
1009
|
+
_SIZER_OPAQUE_TYPES = (types.ModuleType, type, types.FunctionType, types.BuiltinFunctionType, types.MethodType)
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
class _KernelMemory:
|
|
1013
|
+
"""Post-cell footprint measurement, collection, and largest-globals attribution for this process."""
|
|
1014
|
+
|
|
1015
|
+
def __init__(self) -> None:
|
|
1016
|
+
self.thresholds: dict[str, int] | None = None
|
|
1017
|
+
self.last_live = 0
|
|
1018
|
+
self.notice_ref = 0
|
|
1019
|
+
self.collect_end = float("-inf")
|
|
1020
|
+
self.collect_seconds = 0.0
|
|
1021
|
+
self._reader: Callable[[], tuple[int, bool] | None] | None = None
|
|
1022
|
+
self._trim: Callable[[], None] | None = None
|
|
1023
|
+
self._resolved = False
|
|
1024
|
+
|
|
1025
|
+
def configure(self, thresholds: Any) -> None:
|
|
1026
|
+
if not isinstance(thresholds, dict):
|
|
1027
|
+
return
|
|
1028
|
+
keys = ("gcWatermarkBytes", "noticeBytes", "ceilingBytes")
|
|
1029
|
+
if all(isinstance(thresholds.get(key), int) for key in keys):
|
|
1030
|
+
self.thresholds = {key: int(thresholds[key]) for key in keys}
|
|
1031
|
+
|
|
1032
|
+
def after_cell(self) -> dict[str, Any] | None:
|
|
1033
|
+
if self.thresholds is None:
|
|
1034
|
+
return None
|
|
1035
|
+
reading = self._footprint()
|
|
1036
|
+
if reading is None:
|
|
1037
|
+
return None
|
|
1038
|
+
live, approximate = reading
|
|
1039
|
+
gc_ran = self._needs_collection(live)
|
|
1040
|
+
if gc_ran:
|
|
1041
|
+
live = self._collect(live)
|
|
1042
|
+
notice = self.thresholds["noticeBytes"]
|
|
1043
|
+
if gc_ran and notice > 0 and live >= notice:
|
|
1044
|
+
if self.notice_ref == 0 or live >= self.notice_ref * _MEMORY_NOTICE_GROWTH_RATIO:
|
|
1045
|
+
self.notice_ref = live
|
|
1046
|
+
self.last_live = live
|
|
1047
|
+
report: dict[str, Any] = {"liveBytes": live, "measure": "footprint"}
|
|
1048
|
+
if gc_ran:
|
|
1049
|
+
report["gcRan"] = True
|
|
1050
|
+
if approximate:
|
|
1051
|
+
report["approximate"] = True
|
|
1052
|
+
if gc_ran and self._worth_naming(live):
|
|
1053
|
+
named = _largest_globals(_MEMORY_REPORTED_GLOBALS)
|
|
1054
|
+
if named:
|
|
1055
|
+
report["globals"] = named
|
|
1056
|
+
return report
|
|
1057
|
+
|
|
1058
|
+
def _needs_collection(self, estimate: int) -> bool:
|
|
1059
|
+
assert self.thresholds is not None
|
|
1060
|
+
watermark = self.thresholds["gcWatermarkBytes"]
|
|
1061
|
+
notice = self.thresholds["noticeBytes"]
|
|
1062
|
+
ceiling = self.thresholds["ceilingBytes"]
|
|
1063
|
+
if ceiling > 0 and estimate >= ceiling:
|
|
1064
|
+
return True
|
|
1065
|
+
if watermark > 0 and estimate >= watermark:
|
|
1066
|
+
if estimate > self.last_live + max(_MEMORY_MIN_GROWTH, self.last_live * _MEMORY_GROWTH_RATIO):
|
|
1067
|
+
return True
|
|
1068
|
+
if notice > 0 and estimate >= notice:
|
|
1069
|
+
if self.last_live < notice or self.notice_ref == 0 or estimate >= self.notice_ref * _MEMORY_NOTICE_GROWTH_RATIO:
|
|
1070
|
+
return True
|
|
1071
|
+
# The JS worker's idle collection, run synchronously: a cell that only drops a global allocates
|
|
1072
|
+
# nothing, so cyclic garbage and malloc's free lists would otherwise stay until the next growth.
|
|
1073
|
+
if watermark == 0 or self.last_live < watermark:
|
|
1074
|
+
return False
|
|
1075
|
+
return time.monotonic() - self.collect_end >= _MEMORY_COLLECT_RATE_FLOOR * self.collect_seconds
|
|
1076
|
+
|
|
1077
|
+
def _worth_naming(self, live: int) -> bool:
|
|
1078
|
+
assert self.thresholds is not None
|
|
1079
|
+
notice = self.thresholds["noticeBytes"]
|
|
1080
|
+
ceiling = self.thresholds["ceilingBytes"]
|
|
1081
|
+
return (notice > 0 and live >= notice) or (ceiling > 0 and live >= ceiling)
|
|
1082
|
+
|
|
1083
|
+
def _collect(self, before: int) -> int:
|
|
1084
|
+
started = time.monotonic()
|
|
1085
|
+
gc.collect()
|
|
1086
|
+
if self._trim is not None:
|
|
1087
|
+
self._trim()
|
|
1088
|
+
self.collect_end = time.monotonic()
|
|
1089
|
+
self.collect_seconds = self.collect_end - started
|
|
1090
|
+
reading = self._footprint()
|
|
1091
|
+
live = before if reading is None else reading[0]
|
|
1092
|
+
if self.thresholds is not None and live < self.thresholds["noticeBytes"] / 2:
|
|
1093
|
+
self.notice_ref = 0
|
|
1094
|
+
return live
|
|
1095
|
+
|
|
1096
|
+
def _footprint(self) -> tuple[int, bool] | None:
|
|
1097
|
+
if not self._resolved:
|
|
1098
|
+
self._resolved = True
|
|
1099
|
+
self._reader = _footprint_reader()
|
|
1100
|
+
self._trim = _malloc_trim()
|
|
1101
|
+
return None if self._reader is None else self._reader()
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
def _footprint_reader() -> Callable[[], tuple[int, bool]] | None:
|
|
1105
|
+
"""This process's footprint as src/core/process-footprint.ts reads it, or peak RSS (approximate)."""
|
|
1106
|
+
import ctypes
|
|
1107
|
+
|
|
1108
|
+
if sys.platform == "darwin":
|
|
1109
|
+
with contextlib.suppress(OSError, AttributeError):
|
|
1110
|
+
libsystem = ctypes.CDLL("/usr/lib/libSystem.B.dylib")
|
|
1111
|
+
rusage = libsystem.proc_pid_rusage
|
|
1112
|
+
rusage.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_void_p]
|
|
1113
|
+
rusage.restype = ctypes.c_int
|
|
1114
|
+
buffer = (ctypes.c_uint64 * 32)()
|
|
1115
|
+
pid = os.getpid()
|
|
1116
|
+
|
|
1117
|
+
def darwin() -> tuple[int, bool] | None:
|
|
1118
|
+
# RUSAGE_INFO_V2; ri_phys_footprint is the u64 at index 9 (byte offset 72).
|
|
1119
|
+
return (int(buffer[9]), False) if rusage(pid, 2, ctypes.byref(buffer)) == 0 else None
|
|
1120
|
+
|
|
1121
|
+
if darwin() is not None:
|
|
1122
|
+
return darwin
|
|
1123
|
+
if sys.platform.startswith("linux") and os.path.exists("/proc/self/status"):
|
|
1124
|
+
|
|
1125
|
+
def linux() -> tuple[int, bool] | None:
|
|
1126
|
+
with open("/proc/self/status", encoding="ascii", errors="replace") as status:
|
|
1127
|
+
for line in status:
|
|
1128
|
+
if line.startswith("RssAnon:"):
|
|
1129
|
+
return int(line.split()[1]) * 1024, False
|
|
1130
|
+
return None
|
|
1131
|
+
|
|
1132
|
+
if linux() is not None:
|
|
1133
|
+
return linux
|
|
1134
|
+
if sys.platform == "win32":
|
|
1135
|
+
with contextlib.suppress(OSError, AttributeError):
|
|
1136
|
+
return _windows_private_usage_reader(ctypes)
|
|
1137
|
+
with contextlib.suppress(ImportError):
|
|
1138
|
+
import resource
|
|
1139
|
+
|
|
1140
|
+
# ru_maxrss is a peak (kilobytes on Linux, bytes on macOS): flagged approximate.
|
|
1141
|
+
scale = 1 if sys.platform == "darwin" else 1024
|
|
1142
|
+
return lambda: (int(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) * scale, True)
|
|
1143
|
+
return None
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
def _windows_private_usage_reader(ctypes: Any) -> Callable[[], tuple[int, bool]] | None:
|
|
1147
|
+
size_t = ctypes.c_size_t
|
|
1148
|
+
|
|
1149
|
+
class Counters(ctypes.Structure):
|
|
1150
|
+
_fields_ = [
|
|
1151
|
+
("cb", ctypes.c_uint32),
|
|
1152
|
+
("PageFaultCount", ctypes.c_uint32),
|
|
1153
|
+
*((name, size_t) for name in ("PeakWorkingSetSize", "WorkingSetSize", "QuotaPeakPagedPoolUsage")),
|
|
1154
|
+
*((name, size_t) for name in ("QuotaPagedPoolUsage", "QuotaPeakNonPagedPoolUsage")),
|
|
1155
|
+
*((name, size_t) for name in ("QuotaNonPagedPoolUsage", "PagefileUsage", "PeakPagefileUsage")),
|
|
1156
|
+
("PrivateUsage", size_t),
|
|
1157
|
+
]
|
|
1158
|
+
|
|
1159
|
+
kernel32 = ctypes.WinDLL("kernel32")
|
|
1160
|
+
current = kernel32.GetCurrentProcess
|
|
1161
|
+
current.restype = ctypes.c_void_p
|
|
1162
|
+
query = kernel32.K32GetProcessMemoryInfo
|
|
1163
|
+
query.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint32]
|
|
1164
|
+
query.restype = ctypes.c_int
|
|
1165
|
+
counters = Counters()
|
|
1166
|
+
counters.cb = ctypes.sizeof(Counters)
|
|
1167
|
+
|
|
1168
|
+
def windows() -> tuple[int, bool] | None:
|
|
1169
|
+
if query(current(), ctypes.byref(counters), counters.cb) == 0:
|
|
1170
|
+
return None
|
|
1171
|
+
return int(counters.PrivateUsage), False
|
|
1172
|
+
|
|
1173
|
+
return windows if windows() is not None else None
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def _malloc_trim() -> Callable[[], None] | None:
|
|
1177
|
+
"""glibc's malloc_trim(0) returns freed heap pages to the OS; other C libraries have no equivalent."""
|
|
1178
|
+
if not sys.platform.startswith("linux"):
|
|
1179
|
+
return None
|
|
1180
|
+
import ctypes
|
|
1181
|
+
|
|
1182
|
+
with contextlib.suppress(OSError, AttributeError):
|
|
1183
|
+
trim = ctypes.CDLL("libc.so.6").malloc_trim
|
|
1184
|
+
trim.argtypes = [ctypes.c_size_t]
|
|
1185
|
+
trim.restype = ctypes.c_int
|
|
1186
|
+
|
|
1187
|
+
def trim_heap() -> None:
|
|
1188
|
+
trim(0)
|
|
1189
|
+
|
|
1190
|
+
return trim_heap
|
|
1191
|
+
return None
|
|
1192
|
+
|
|
1193
|
+
|
|
1194
|
+
def _largest_globals(limit: int) -> list[dict[str, Any]]:
|
|
1195
|
+
sizer = _GlobalSizer()
|
|
1196
|
+
sized: list[dict[str, Any]] = []
|
|
1197
|
+
for name, value in list(USER_NS.items()):
|
|
1198
|
+
if name in _MEMORY_BASELINE or name.startswith("__") or isinstance(value, _SIZER_OPAQUE_TYPES):
|
|
1199
|
+
continue
|
|
1200
|
+
measured = sizer.measure(value)
|
|
1201
|
+
if measured is None or measured[0] < _MEMORY_MIN_REPORTED_BYTES:
|
|
1202
|
+
continue
|
|
1203
|
+
entry: dict[str, Any] = {"name": name, "bytes": measured[0]}
|
|
1204
|
+
if measured[1]:
|
|
1205
|
+
entry["approximate"] = True
|
|
1206
|
+
sized.append(entry)
|
|
1207
|
+
sized.sort(key=lambda entry: entry["bytes"], reverse=True)
|
|
1208
|
+
return sized[:limit]
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
class _GlobalSizer:
|
|
1212
|
+
"""Estimated retained size of user globals: array/frame buffers from numpy and pandas, containers from
|
|
1213
|
+
their length and up to 1,000 sampled elements, one shared visited set and node budget for the walk."""
|
|
1214
|
+
|
|
1215
|
+
def __init__(self) -> None:
|
|
1216
|
+
self.seen: set[int] = set()
|
|
1217
|
+
self.nodes = 0
|
|
1218
|
+
self.approximate = False
|
|
1219
|
+
|
|
1220
|
+
def measure(self, value: Any) -> tuple[int, bool] | None:
|
|
1221
|
+
self.approximate = False
|
|
1222
|
+
try:
|
|
1223
|
+
return int(self.size(value, 0)), self.approximate
|
|
1224
|
+
except Exception: # noqa: BROAD_EXCEPT_OK — an exotic user value (a raising __len__/__iter__) stays unsized.
|
|
1225
|
+
return None
|
|
1226
|
+
|
|
1227
|
+
def size(self, value: Any, depth: int) -> float:
|
|
1228
|
+
if id(value) in self.seen:
|
|
1229
|
+
return 0
|
|
1230
|
+
if depth >= _SIZER_MAX_DEPTH or self.nodes >= _SIZER_NODE_BUDGET:
|
|
1231
|
+
self.approximate = True
|
|
1232
|
+
return 0
|
|
1233
|
+
self.seen.add(id(value))
|
|
1234
|
+
self.nodes += 1
|
|
1235
|
+
buffer = _buffer_bytes(value)
|
|
1236
|
+
if buffer is not None:
|
|
1237
|
+
return buffer
|
|
1238
|
+
own = sys.getsizeof(value)
|
|
1239
|
+
if isinstance(value, _SIZER_LEAF_TYPES) or isinstance(value, _SIZER_OPAQUE_TYPES):
|
|
1240
|
+
return own
|
|
1241
|
+
if isinstance(value, dict):
|
|
1242
|
+
return own + self.sampled(len(value), iter(value.items()), depth, pairs=True)
|
|
1243
|
+
if isinstance(value, (list, tuple)):
|
|
1244
|
+
return own + self.spaced(value, depth)
|
|
1245
|
+
if isinstance(value, (set, frozenset)) or type(value).__module__ == "collections":
|
|
1246
|
+
return own + self.sampled(len(value), iter(value), depth, pairs=False)
|
|
1247
|
+
attributes = _instance_dict(value)
|
|
1248
|
+
return own if attributes is None else own + self.size(attributes, depth + 1)
|
|
1249
|
+
|
|
1250
|
+
def spaced(self, items: Any, depth: int) -> float:
|
|
1251
|
+
count = len(items)
|
|
1252
|
+
if count <= _SIZER_SAMPLED:
|
|
1253
|
+
return sum(self.size(item, depth + 1) for item in items)
|
|
1254
|
+
self.approximate = True
|
|
1255
|
+
step = count / _SIZER_SAMPLED
|
|
1256
|
+
total = sum(self.size(items[int(index * step)], depth + 1) for index in range(_SIZER_SAMPLED))
|
|
1257
|
+
return total / _SIZER_SAMPLED * count
|
|
1258
|
+
|
|
1259
|
+
def sampled(self, count: int, entries: Any, depth: int, *, pairs: bool) -> float:
|
|
1260
|
+
taken = []
|
|
1261
|
+
for entry in entries:
|
|
1262
|
+
taken.append(entry)
|
|
1263
|
+
if len(taken) >= _SIZER_SAMPLED:
|
|
1264
|
+
break
|
|
1265
|
+
if not taken:
|
|
1266
|
+
return 0
|
|
1267
|
+
if len(taken) < count:
|
|
1268
|
+
self.approximate = True
|
|
1269
|
+
total = 0.0
|
|
1270
|
+
for entry in taken:
|
|
1271
|
+
if pairs:
|
|
1272
|
+
total += self.size(entry[0], depth + 1) + self.size(entry[1], depth + 1)
|
|
1273
|
+
else:
|
|
1274
|
+
total += self.size(entry, depth + 1)
|
|
1275
|
+
return total * count / len(taken)
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
def _buffer_bytes(value: Any) -> int | None:
|
|
1279
|
+
root = type(value).__module__.split(".", 1)[0]
|
|
1280
|
+
if root == "numpy" and isinstance(getattr(value, "nbytes", None), int):
|
|
1281
|
+
return int(value.nbytes)
|
|
1282
|
+
if root == "pandas" and callable(getattr(value, "memory_usage", None)):
|
|
1283
|
+
usage = value.memory_usage(deep=True)
|
|
1284
|
+
return int(usage.sum()) if hasattr(usage, "sum") else int(usage)
|
|
1285
|
+
return None
|
|
1286
|
+
|
|
1287
|
+
|
|
1288
|
+
def _instance_dict(value: Any) -> dict[str, Any] | None:
|
|
1289
|
+
# object.__getattribute__ never falls back to a user __getattr__.
|
|
1290
|
+
try:
|
|
1291
|
+
attributes = object.__getattribute__(value, "__dict__")
|
|
1292
|
+
except AttributeError:
|
|
1293
|
+
return None
|
|
1294
|
+
return attributes if isinstance(attributes, dict) else None
|
|
1295
|
+
|
|
1296
|
+
|
|
1297
|
+
KERNEL_MEMORY = _KernelMemory()
|
|
1298
|
+
|
|
990
1299
|
TLA_FLAG = getattr(ast, "PyCF_ALLOW_TOP_LEVEL_AWAIT", 0x2000)
|
|
991
1300
|
|
|
992
1301
|
|
|
@@ -1039,6 +1348,7 @@ def run_cell(cell_id: str, code: str, preludes: Any = None) -> None:
|
|
|
1039
1348
|
# SIGINT must interrupt user code here; the idle baseline (set between
|
|
1040
1349
|
# cells) ignores it so a late signal cannot kill the stdin-read loop.
|
|
1041
1350
|
signal.signal(signal.SIGINT, signal.default_int_handler)
|
|
1351
|
+
result: dict[str, Any]
|
|
1042
1352
|
try:
|
|
1043
1353
|
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
|
|
1044
1354
|
apply_preludes(preludes)
|
|
@@ -1047,7 +1357,7 @@ def run_cell(cell_id: str, code: str, preludes: Any = None) -> None:
|
|
|
1047
1357
|
value = LOOP.run_until_complete(run_code(expression, True))
|
|
1048
1358
|
text("stdout", stdout.getvalue())
|
|
1049
1359
|
text("stderr", stderr.getvalue())
|
|
1050
|
-
result
|
|
1360
|
+
result = {
|
|
1051
1361
|
"type": "result",
|
|
1052
1362
|
"cellId": cell_id,
|
|
1053
1363
|
"ok": True,
|
|
@@ -1055,21 +1365,26 @@ def run_cell(cell_id: str, code: str, preludes: Any = None) -> None:
|
|
|
1055
1365
|
}
|
|
1056
1366
|
if value is not None:
|
|
1057
1367
|
result["valueRepr"] = repr(value)
|
|
1058
|
-
emit(result)
|
|
1059
1368
|
except BaseException as exc: # noqa: BROAD_EXCEPT_OK — cell boundary serializes user errors and interrupts.
|
|
1060
1369
|
text("stdout", stdout.getvalue())
|
|
1061
1370
|
text("stderr", stderr.getvalue())
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
}
|
|
1070
|
-
)
|
|
1371
|
+
result = {
|
|
1372
|
+
"type": "result",
|
|
1373
|
+
"cellId": cell_id,
|
|
1374
|
+
"ok": False,
|
|
1375
|
+
"error": bridge_error(exc),
|
|
1376
|
+
"durationMs": elapsed(start),
|
|
1377
|
+
}
|
|
1071
1378
|
finally:
|
|
1072
1379
|
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
1380
|
+
try:
|
|
1381
|
+
memory = KERNEL_MEMORY.after_cell()
|
|
1382
|
+
except Exception as exc: # noqa: BROAD_EXCEPT_OK — a measurement failure must not cost the cell its result.
|
|
1383
|
+
memory = None
|
|
1384
|
+
text("stderr", f"[senpi] kernel memory measurement failed: {exc}\n")
|
|
1385
|
+
if memory is not None:
|
|
1386
|
+
result["memory"] = memory
|
|
1387
|
+
emit(result)
|
|
1073
1388
|
|
|
1074
1389
|
|
|
1075
1390
|
def elapsed(start: float) -> int:
|
|
@@ -1085,6 +1400,7 @@ def handle(message: dict[str, Any]) -> bool:
|
|
|
1085
1400
|
emit({"type": "init-failed", "error": {"message": "missing bridge connection"}})
|
|
1086
1401
|
return True
|
|
1087
1402
|
CONNECTION = connection
|
|
1403
|
+
KERNEL_MEMORY.configure(message.get("memory"))
|
|
1088
1404
|
emit({"type": "ready"})
|
|
1089
1405
|
return True
|
|
1090
1406
|
if message_type == "run":
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { dirname, join } from "node:path";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
|
+
import type { KernelMemoryThresholds } from "../../bridge/memory-protocol.ts";
|
|
3
4
|
import {
|
|
4
5
|
type BridgeConnectionConfig,
|
|
5
6
|
decodeBridgeFrame,
|
|
@@ -43,6 +44,7 @@ export interface PythonTransportOptions {
|
|
|
43
44
|
/** Per-session PI_* values merged into the interpreter environment at spawn. */
|
|
44
45
|
readonly sessionEnv?: SessionEnvironment;
|
|
45
46
|
readonly startupTimeoutMs: number;
|
|
47
|
+
readonly memory?: KernelMemoryThresholds;
|
|
46
48
|
readonly onMessage?: (message: KernelToHostMessage) => void;
|
|
47
49
|
readonly spawnProcess?: KernelSpawnProcess;
|
|
48
50
|
readonly isOwned: () => boolean;
|
|
@@ -188,7 +190,8 @@ export class PythonKernelTransport {
|
|
|
188
190
|
this.#child.stderr.on("data", onStderr);
|
|
189
191
|
this.#child.on("error", onError);
|
|
190
192
|
this.#child.on("exit", onExit);
|
|
191
|
-
|
|
193
|
+
const { sessionId, connection, memory } = this.#options;
|
|
194
|
+
this.#write({ type: "init", sessionId, connection, ...(memory === undefined ? {} : { memory }) });
|
|
192
195
|
await withTimeout(ready, this.#options.startupTimeoutMs, "Python kernel did not become ready");
|
|
193
196
|
}
|
|
194
197
|
|
package/src/kernels/rb/kernel.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { join } from "node:path";
|
|
|
2
2
|
import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
3
3
|
import type { SessionEnvironment } from "../session-env.ts";
|
|
4
4
|
import { type CodemodeRuntimeAssetEnvironment, requireCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
|
|
5
|
-
import { SubprocessKernel, type SubprocessSpawn } from "../shared/subprocess-kernel.ts";
|
|
5
|
+
import { SubprocessKernel, type SubprocessKernelMemory, type SubprocessSpawn } from "../shared/subprocess-kernel.ts";
|
|
6
6
|
|
|
7
7
|
export interface RubyKernelStartOptions {
|
|
8
8
|
readonly cwd: string;
|
|
@@ -13,6 +13,8 @@ export interface RubyKernelStartOptions {
|
|
|
13
13
|
readonly command?: string;
|
|
14
14
|
readonly spawn?: SubprocessSpawn;
|
|
15
15
|
readonly onMessage?: (message: KernelToHostMessage) => void;
|
|
16
|
+
/** Ceiling-only memory management (no notice or globals list: the runner reports no memory). */
|
|
17
|
+
readonly memory?: SubprocessKernelMemory;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
export interface RubyRunnerPathOptions extends CodemodeRuntimeAssetEnvironment {
|
|
@@ -38,6 +40,7 @@ export class RubyKernel extends SubprocessKernel {
|
|
|
38
40
|
connection: options.connection,
|
|
39
41
|
spawn: options.spawn,
|
|
40
42
|
onMessage: options.onMessage,
|
|
43
|
+
memory: options.memory && { language: "rb", ...options.memory },
|
|
41
44
|
});
|
|
42
45
|
}
|
|
43
46
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { KernelMemoryThresholds } from "../../bridge/memory-protocol.ts";
|
|
2
|
+
import type { KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
3
|
+
import type { EvalLanguage } from "../../tool/types.ts";
|
|
4
|
+
import { KernelMemoryPolicy } from "./kernel-memory.ts";
|
|
5
|
+
|
|
6
|
+
type ResultMessage = Extract<KernelToHostMessage, { type: "result" }>;
|
|
7
|
+
|
|
8
|
+
/** Reads another process's memory footprint; `readProcessFootprint` from `@code-yeongyu/senpi` in production. */
|
|
9
|
+
export type FootprintReader = (pid: number) => { readonly bytes: number } | undefined;
|
|
10
|
+
|
|
11
|
+
export interface KernelMemoryHostOptions {
|
|
12
|
+
/**
|
|
13
|
+
* Host-measured kernel (rb, jl): the kernel reports no memory itself, so the host reads the
|
|
14
|
+
* interpreter's footprint after each result. Such a kernel gets the ceiling only - no notice, no globals.
|
|
15
|
+
*/
|
|
16
|
+
readonly readFootprint?: FootprintReader;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The kernel-host half of the memory contract for kernels that restart by replacing their process
|
|
21
|
+
* (py, rb, jl): annotates each settled result through the shared {@link KernelMemoryPolicy} and tells the
|
|
22
|
+
* host when an over-ceiling kernel may restart - only once no cell is running or queued on it, so a cell
|
|
23
|
+
* queued behind the offending one still sees its globals and no result is ever lost.
|
|
24
|
+
*/
|
|
25
|
+
export class KernelMemoryHost {
|
|
26
|
+
readonly #policy: KernelMemoryPolicy;
|
|
27
|
+
readonly #readFootprint: FootprintReader | undefined;
|
|
28
|
+
|
|
29
|
+
constructor(language: EvalLanguage, thresholds: KernelMemoryThresholds, options: KernelMemoryHostOptions = {}) {
|
|
30
|
+
this.#readFootprint = options.readFootprint;
|
|
31
|
+
this.#policy =
|
|
32
|
+
this.#readFootprint === undefined
|
|
33
|
+
? new KernelMemoryPolicy(language, thresholds)
|
|
34
|
+
: new KernelMemoryPolicy(language, { ...thresholds, noticeBytes: 0 }, { collects: false });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
annotate(result: ResultMessage, pid?: number): ResultMessage {
|
|
38
|
+
const report =
|
|
39
|
+
this.#readFootprint === undefined ? result.memory : this.#footprintReport(this.#readFootprint, pid);
|
|
40
|
+
return report === undefined ? result : { ...result, memory: this.#policy.annotate(report) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* True when the kernel must restart now: it went over its ceiling and `idle` (nothing running or queued).
|
|
45
|
+
* The next result the kernel produces then carries `recycled: true` and the restarted notice.
|
|
46
|
+
*/
|
|
47
|
+
claimRecycle(idle: boolean): boolean {
|
|
48
|
+
if (!idle || !this.#policy.recyclePending) return false;
|
|
49
|
+
this.#policy.recycleStarted();
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
processReplaced(): void {
|
|
54
|
+
this.#policy.kernelRetired();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
#footprintReport(read: FootprintReader, pid: number | undefined) {
|
|
58
|
+
if (pid === undefined) return undefined;
|
|
59
|
+
const footprint = read(pid);
|
|
60
|
+
if (footprint === undefined) return undefined;
|
|
61
|
+
return { liveBytes: Math.round(footprint.bytes), measure: "footprint" as const };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { KernelMemoryGlobal, KernelMemoryReport, KernelMemoryThresholds } from "../../bridge/memory-protocol.ts";
|
|
2
|
+
import type { EvalLanguage } from "../../tool/types.ts";
|
|
3
|
+
|
|
4
|
+
const NOTICE_GROWTH_RATIO = 1.25;
|
|
5
|
+
const NOTICED_GLOBALS = 5;
|
|
6
|
+
const MIB = 1024 * 1024;
|
|
7
|
+
const GIB = 1024 * MIB;
|
|
8
|
+
|
|
9
|
+
interface CeilingBreach {
|
|
10
|
+
readonly liveBytes: number;
|
|
11
|
+
readonly globals: readonly KernelMemoryGlobal[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface KernelMemoryPolicyOptions {
|
|
15
|
+
/**
|
|
16
|
+
* The kernel collects before it reports (js, py): only readings taken right after a collection
|
|
17
|
+
* (`gcRan`) count toward the notice and the ceiling. A kernel whose footprint the host reads without
|
|
18
|
+
* any collection (rb, jl) sets this to `false`, and every reading counts toward the ceiling.
|
|
19
|
+
*/
|
|
20
|
+
readonly collects?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** How a cell drops a global in each language, named in the large-globals notice. */
|
|
24
|
+
const DROP_ADVICE: Readonly<Record<EvalLanguage, (name: string) => string>> = {
|
|
25
|
+
js: (name) => `${name} = undefined`,
|
|
26
|
+
py: (name) => `del ${name}`,
|
|
27
|
+
rb: (name) => `${name} = nil`,
|
|
28
|
+
jl: (name) => `${name} = nothing`,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Per-kernel memory policy: decides which results carry the large-globals notice (hysteresis per
|
|
33
|
+
* language), when live memory crossed the ceiling, and which result announces the restart. Pure state;
|
|
34
|
+
* the kernel host performs the restart once its run queue is empty.
|
|
35
|
+
*/
|
|
36
|
+
export class KernelMemoryPolicy {
|
|
37
|
+
readonly #language: EvalLanguage;
|
|
38
|
+
readonly #thresholds: KernelMemoryThresholds;
|
|
39
|
+
readonly #collects: boolean;
|
|
40
|
+
#lastNotifiedLive = 0;
|
|
41
|
+
#lastObservedLive = 0;
|
|
42
|
+
#pendingRecycle: CeilingBreach | null = null;
|
|
43
|
+
#announceRecycle: CeilingBreach | null = null;
|
|
44
|
+
|
|
45
|
+
constructor(language: EvalLanguage, thresholds: KernelMemoryThresholds, options: KernelMemoryPolicyOptions = {}) {
|
|
46
|
+
this.#language = language;
|
|
47
|
+
this.#thresholds = thresholds;
|
|
48
|
+
this.#collects = options.collects ?? true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get thresholds(): KernelMemoryThresholds {
|
|
52
|
+
return this.#thresholds;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get recyclePending(): boolean {
|
|
56
|
+
return this.#pendingRecycle !== null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Any measurement below half the notice threshold re-arms the notice for a later large global. */
|
|
60
|
+
observeLive(liveBytes: number): void {
|
|
61
|
+
if (liveBytes < this.#thresholds.noticeBytes / 2) this.#lastNotifiedLive = 0;
|
|
62
|
+
this.#lastObservedLive = liveBytes;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
annotate(report: KernelMemoryReport): KernelMemoryReport {
|
|
66
|
+
const crossedUpward = this.#lastObservedLive < this.#thresholds.noticeBytes;
|
|
67
|
+
this.observeLive(report.liveBytes);
|
|
68
|
+
const announced = this.#announceRecycle;
|
|
69
|
+
if (announced !== null) {
|
|
70
|
+
this.#announceRecycle = null;
|
|
71
|
+
return { ...report, recycled: true, notice: this.#recycledNotice(announced) };
|
|
72
|
+
}
|
|
73
|
+
if (this.#collects && report.gcRan !== true) return report;
|
|
74
|
+
const { ceilingBytes, noticeBytes } = this.#thresholds;
|
|
75
|
+
if (ceilingBytes > 0 && report.liveBytes >= ceilingBytes) return this.#overCeiling(report);
|
|
76
|
+
if (noticeBytes === 0 || report.liveBytes < noticeBytes) return report;
|
|
77
|
+
const grew = report.liveBytes >= this.#lastNotifiedLive * NOTICE_GROWTH_RATIO;
|
|
78
|
+
if (!crossedUpward && !grew) return report;
|
|
79
|
+
this.#lastNotifiedLive = report.liveBytes;
|
|
80
|
+
return { ...report, notice: this.#sizeNotice(report) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The host is restarting the kernel; the next result it produces announces the restart. */
|
|
84
|
+
recycleStarted(): void {
|
|
85
|
+
this.#announceRecycle = this.#pendingRecycle;
|
|
86
|
+
this.#pendingRecycle = null;
|
|
87
|
+
this.#lastNotifiedLive = 0;
|
|
88
|
+
this.#lastObservedLive = 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The worker or process is gone for another reason (reset, crash, stop): its memory went with it. */
|
|
92
|
+
kernelRetired(): void {
|
|
93
|
+
this.#pendingRecycle = null;
|
|
94
|
+
this.#lastNotifiedLive = 0;
|
|
95
|
+
this.#lastObservedLive = 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#overCeiling(report: KernelMemoryReport): KernelMemoryReport {
|
|
99
|
+
this.#lastNotifiedLive = report.liveBytes;
|
|
100
|
+
if (this.#pendingRecycle !== null) return { ...report, overCeiling: true };
|
|
101
|
+
const breach = { liveBytes: report.liveBytes, globals: report.globals ?? [] };
|
|
102
|
+
this.#pendingRecycle = breach;
|
|
103
|
+
return { ...report, overCeiling: true, notice: this.#ceilingNotice(breach) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
#sizeNotice(report: KernelMemoryReport): string {
|
|
107
|
+
const globals = report.globals ?? [];
|
|
108
|
+
const drop = DROP_ADVICE[this.#language](globals[0]?.name ?? "name");
|
|
109
|
+
return `[${this.#language} kernel holds ${formatBytes(report.liveBytes)} ${this.#liveMeasure} (notice at ${formatBytes(this.#thresholds.noticeBytes)}).${globalsPhrase("Largest globals", globals)} Drop what you no longer need (${drop}) or run with reset: true.]`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
#ceilingNotice(breach: CeilingBreach): string {
|
|
113
|
+
return `[${this.#language} kernel holds ${formatBytes(breach.liveBytes)} ${this.#liveMeasure}, over the ${formatBytes(this.#thresholds.ceilingBytes)} ceiling. It restarts before the next cell you submit and every global is lost.${globalsPhrase("Largest globals", breach.globals)}]`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
#recycledNotice(breach: CeilingBreach): string {
|
|
117
|
+
return `[${this.#language} kernel was restarted before this cell: ${formatBytes(breach.liveBytes)} ${this.#liveMeasure} exceeded the ${formatBytes(this.#thresholds.ceilingBytes)} ceiling. Every global from earlier cells is gone.${globalsPhrase("Largest globals were", breach.globals)}]`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
get #liveMeasure(): string {
|
|
121
|
+
return this.#collects ? "live after GC" : "in its process footprint";
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function formatBytes(bytes: number): string {
|
|
126
|
+
if (bytes >= GIB) return `${Number((bytes / GIB).toFixed(1))} GB`;
|
|
127
|
+
return `${Math.max(1, Math.round(bytes / MIB))} MB`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function globalsPhrase(label: string, globals: readonly KernelMemoryGlobal[]): string {
|
|
131
|
+
if (globals.length === 0) return "";
|
|
132
|
+
const listed = globals
|
|
133
|
+
.slice(0, NOTICED_GLOBALS)
|
|
134
|
+
.map((global) => `${global.name} ${global.approximate === true ? "~" : ""}${formatBytes(global.bytes)}`);
|
|
135
|
+
return ` ${label}: ${listed.join(", ")}.`;
|
|
136
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import type { KernelMemoryThresholds } from "../../bridge/memory-protocol.ts";
|
|
1
2
|
import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
2
|
-
import type { EvalKernelRunInput } from "../../tool/types.ts";
|
|
3
|
+
import type { EvalKernelRunInput, EvalLanguage } from "../../tool/types.ts";
|
|
3
4
|
import type { SessionEnvironment } from "../session-env.ts";
|
|
5
|
+
import type { FootprintReader } from "./kernel-memory-host.ts";
|
|
4
6
|
import type { SubprocessSpawn } from "./subprocess-process.ts";
|
|
5
7
|
|
|
6
8
|
export type KernelRunInput = EvalKernelRunInput;
|
|
@@ -8,6 +10,12 @@ export type KernelRunInput = EvalKernelRunInput;
|
|
|
8
10
|
export type KernelResult = Extract<KernelToHostMessage, { type: "result" }>;
|
|
9
11
|
export type ToolCallMessage = Extract<KernelToHostMessage, { type: "tool-call" }>;
|
|
10
12
|
|
|
13
|
+
/** Ceiling-only memory management: the host reads the interpreter footprint after each result. */
|
|
14
|
+
export interface SubprocessKernelMemory {
|
|
15
|
+
readonly thresholds: KernelMemoryThresholds;
|
|
16
|
+
readonly readFootprint: FootprintReader;
|
|
17
|
+
}
|
|
18
|
+
|
|
11
19
|
export interface SubprocessKernelOptions {
|
|
12
20
|
readonly command: string;
|
|
13
21
|
readonly args: readonly string[];
|
|
@@ -19,4 +27,5 @@ export interface SubprocessKernelOptions {
|
|
|
19
27
|
readonly connection: BridgeConnectionConfig;
|
|
20
28
|
readonly spawn?: SubprocessSpawn;
|
|
21
29
|
readonly onMessage?: (message: KernelToHostMessage) => void;
|
|
30
|
+
readonly memory?: SubprocessKernelMemory & { readonly language: EvalLanguage };
|
|
22
31
|
}
|