@code-yeongyu/senpi-codemode 2026.9.28-3 → 2026.9.28-4

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +13 -1
  3. package/package.json +4 -4
  4. package/src/bridge/memory-protocol.ts +39 -0
  5. package/src/bridge/protocol.ts +4 -0
  6. package/src/bridge/reserved.ts +2 -0
  7. package/src/config/memory-settings.ts +149 -0
  8. package/src/config/settings.ts +8 -2
  9. package/src/extension/session-manager-proxy.ts +6 -0
  10. package/src/extension/session-manager.ts +27 -25
  11. package/src/host-sdk.ts +1 -1
  12. package/src/index.ts +4 -0
  13. package/src/kernels/jl/kernel.ts +4 -1
  14. package/src/kernels/js/context-manager.ts +43 -5
  15. package/src/kernels/js/interrupt-bounds.ts +8 -2
  16. package/src/kernels/js/kernel-contract.ts +13 -0
  17. package/src/kernels/js/worker-core.js +15 -3
  18. package/src/kernels/js/worker-global-sizes.js +123 -0
  19. package/src/kernels/js/worker-heap.js +59 -0
  20. package/src/kernels/js/worker-memory.js +94 -0
  21. package/src/kernels/js/worker-runtime.js +5 -1
  22. package/src/kernels/js/worker-slot.ts +3 -1
  23. package/src/kernels/js/worker-startup.ts +1 -0
  24. package/src/kernels/py/kernel-contract.ts +3 -0
  25. package/src/kernels/py/kernel.ts +15 -2
  26. package/src/kernels/py/prelude.py +327 -11
  27. package/src/kernels/py/transport.ts +4 -1
  28. package/src/kernels/rb/kernel.ts +4 -1
  29. package/src/kernels/shared/kernel-memory-host.ts +63 -0
  30. package/src/kernels/shared/kernel-memory.ts +136 -0
  31. package/src/kernels/shared/subprocess-contract.ts +10 -1
  32. package/src/kernels/shared/subprocess-kernel.ts +23 -2
  33. package/src/output/streaming-output-buffer.ts +229 -12
  34. package/src/output/streaming-output.ts +2 -2
  35. package/src/prompt/eval-prompt.ts +1 -1
  36. package/src/tool/cell-runtime.ts +52 -10
  37. package/src/tool/detached-cell-contract.ts +4 -0
  38. package/src/tool/detached-cell-manager.ts +16 -1
  39. package/src/tool/detached-cell-notification.ts +10 -1
  40. package/src/tool/image.ts +6 -2
  41. package/src/tool/run-eval-cell.ts +19 -17
  42. package/src/tool/settled-image-spill.ts +108 -0
  43. package/src/tool/terminal-snapshot-store.ts +82 -10
  44. package/src/tool/types.ts +10 -0
@@ -1,7 +1,14 @@
1
1
  import type { EvalStatusEvent, HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
2
- import { CHILD_LIFECYCLE_OP, INTERRUPT_ACK_OP } from "../../bridge/reserved.ts";
2
+ import { CHILD_LIFECYCLE_OP, INTERRUPT_ACK_OP, MEMORY_COLLECTED_OP } from "../../bridge/reserved.ts";
3
3
  import type { KernelInterruptHandle } from "../../tool/types.ts";
4
- import { abandonedWorkerNote, awaitCooperativeSettlement, type WorkerRetirement } from "./interrupt-bounds.ts";
4
+ import { KernelMemoryPolicy } from "../shared/kernel-memory.ts";
5
+ import {
6
+ abandonedWorkerNote,
7
+ awaitCooperativeSettlement,
8
+ DEFAULT_INTERRUPT_BOUNDS,
9
+ type JavaScriptInterruptBounds,
10
+ type WorkerRetirement,
11
+ } from "./interrupt-bounds.ts";
5
12
  import {
6
13
  assertJavaScriptKernelOpen,
7
14
  type JavaScriptKernelMode,
@@ -53,9 +60,13 @@ export class JavaScriptKernel {
53
60
  #pendingToolCalls: ToolCallMessage[] = [];
54
61
  /** Live cell children the worker reported; retired by the host when the worker itself is lost. */
55
62
  readonly #childPids = new Set<number>();
63
+ readonly #memory: KernelMemoryPolicy | null;
64
+ readonly #interruptBounds: JavaScriptInterruptBounds;
56
65
 
57
66
  constructor(options: JavaScriptKernelOptions) {
58
67
  this.#options = options;
68
+ this.#interruptBounds = options.interruptBounds ?? DEFAULT_INTERRUPT_BOUNDS;
69
+ this.#memory = options.memory === undefined ? null : new KernelMemoryPolicy("js", options.memory);
59
70
  this.#moduleLoader = new LocalModuleLoader(options);
60
71
  this.#slot = new WorkerSlot(options, {
61
72
  isOpen: () => this.#lifecycle === "open",
@@ -226,12 +237,16 @@ export class JavaScriptKernel {
226
237
  run.interruptResult = { type: "result", cellId: run.input.cellId, ok: false, error: { message }, durationMs };
227
238
  run.interruptAck ??= Promise.withResolvers<void>();
228
239
  this.#slot.postMessage({ type: "interrupt", reason });
229
- if ((await awaitCooperativeSettlement(run)) === "settled") return { retained: run.settledByWorker };
240
+ if ((await awaitCooperativeSettlement(run, this.#interruptBounds)) === "settled") {
241
+ return { retained: run.settledByWorker };
242
+ }
230
243
  if (!this.#runs.releaseActive(run)) return { retained: run.settledByWorker };
231
244
  const retirement = await this.#terminate();
232
245
  this.#runs.settle(run, run.interruptResult ?? stoppedResult(run.input.cellId, message));
233
246
  void this.#recover(() => Promise.resolve());
234
- return retirement === "abandoned" ? { retained: false, note: abandonedWorkerNote() } : { retained: false };
247
+ return retirement === "abandoned"
248
+ ? { retained: false, note: abandonedWorkerNote(this.#interruptBounds.terminateDeadlineMs) }
249
+ : { retained: false };
235
250
  } finally {
236
251
  this.#clearToolCalls();
237
252
  }
@@ -275,6 +290,13 @@ export class JavaScriptKernel {
275
290
  this.#trackChildEvent(message.event);
276
291
  return;
277
292
  }
293
+ if (message.type === "status" && message.event.op === MEMORY_COLLECTED_OP) {
294
+ const liveBytes = message.event.liveBytes;
295
+ if (typeof liveBytes !== "number") return;
296
+ this.#memory?.observeLive(liveBytes);
297
+ this.#options.onMemoryCollected?.(liveBytes);
298
+ return;
299
+ }
278
300
  (this.#runs.active?.input.onMessage ?? this.#options.onMessage)?.(message);
279
301
  if (message.type === "tool-call") {
280
302
  const waiter = this.#toolWaiters.shift();
@@ -291,8 +313,21 @@ export class JavaScriptKernel {
291
313
  this.#clearTimeout();
292
314
  this.#runs.releaseActive(active);
293
315
  active.settledByWorker = true;
294
- this.#runs.settle(active, active.interruptResult ?? message);
316
+ this.#runs.settle(active, active.interruptResult ?? this.#withMemoryPolicy(message));
295
317
  this.#startNext();
318
+ this.#recycleOverCeilingWhenIdle();
319
+ }
320
+
321
+ #withMemoryPolicy(message: ResultMessage): ResultMessage {
322
+ if (this.#memory === null || message.memory === undefined) return message;
323
+ return { ...message, memory: this.#memory.annotate(message.memory) };
324
+ }
325
+
326
+ /** A kernel over its memory ceiling restarts only once no cell is running or queued on it. */
327
+ #recycleOverCeilingWhenIdle(): void {
328
+ if (this.#memory?.recyclePending !== true || this.#runs.active || this.#runs.hasWaiting) return;
329
+ this.#memory.recycleStarted();
330
+ void this.#restartAfterStop();
296
331
  }
297
332
 
298
333
  #handleCrash(error: Error): void {
@@ -338,6 +373,7 @@ export class JavaScriptKernel {
338
373
  async #terminate(): Promise<WorkerRetirement> {
339
374
  this.#clearTimeout();
340
375
  this.#kernelTools.rejectAll(kernelToolError("kernel_tool_stale", "JavaScript worker reset"));
376
+ this.#memory?.kernelRetired();
341
377
  const retirement = await this.#slot.retire();
342
378
  await this.#retireWorkerChildren();
343
379
  return retirement;
@@ -348,5 +384,7 @@ export class JavaScriptKernel {
348
384
  const pids = [...this.#childPids];
349
385
  this.#childPids.clear();
350
386
  await terminateProcessTrees(pids, { graceMs: WORKER_LOSS_CHILD_GRACE_MS, ownerPid: process.pid });
387
+ // The worker that owned these children and their exit watchers is gone, so nothing else will wait on them.
388
+ await this.#options.collectOrphanedChildren?.(pids);
351
389
  }
352
390
  }
@@ -16,14 +16,20 @@ export interface CooperativeSettlementBounds {
16
16
  readonly graceMs: number;
17
17
  }
18
18
 
19
- const DEFAULT_SETTLEMENT_BOUNDS: CooperativeSettlementBounds = {
19
+ /** Every deadline an interrupt can hit; a kernel built with its own bounds chooses which stop path a slow cell takes. */
20
+ export interface JavaScriptInterruptBounds extends CooperativeSettlementBounds {
21
+ readonly terminateDeadlineMs: number;
22
+ }
23
+
24
+ export const DEFAULT_INTERRUPT_BOUNDS: JavaScriptInterruptBounds = {
20
25
  ackMs: INTERRUPT_ACK_MS,
21
26
  graceMs: JS_INTERRUPT_GRACE_MS,
27
+ terminateDeadlineMs: WORKER_TERMINATE_DEADLINE_MS,
22
28
  };
23
29
 
24
30
  export async function awaitCooperativeSettlement(
25
31
  run: PendingJavaScriptRun,
26
- bounds = DEFAULT_SETTLEMENT_BOUNDS,
32
+ bounds: CooperativeSettlementBounds = DEFAULT_INTERRUPT_BOUNDS,
27
33
  ): Promise<CooperativeSettlement> {
28
34
  if (run.settled) return "settled";
29
35
  const settled = run.settlement.then((): "settled" => "settled");
@@ -1,6 +1,8 @@
1
+ import type { KernelMemoryThresholds } from "../../bridge/memory-protocol.ts";
1
2
  import type { KernelToHostMessage } from "../../bridge/protocol.ts";
2
3
  import type { EvalKernelRunInput } from "../../tool/types.ts";
3
4
  import type { SessionEnvironment } from "../session-env.ts";
5
+ import type { JavaScriptInterruptBounds } from "./interrupt-bounds.ts";
4
6
 
5
7
  export type ResultMessage = Extract<KernelToHostMessage, { type: "result" }>;
6
8
  export type ToolCallMessage = Extract<KernelToHostMessage, { type: "tool-call" }>;
@@ -18,6 +20,11 @@ export function resolveKernelToolNameSource(names?: KernelToolNameSource): strin
18
20
  export interface JavaScriptKernelOptions {
19
21
  readonly sessionId: string;
20
22
  readonly cwd: string;
23
+ /**
24
+ * Collects the exit status of a retired worker's children once they are killed (#1962): no thread is left to
25
+ * wait on them. Absent, they stay zombies until the host's child reaper or process exit collects them.
26
+ */
27
+ readonly collectOrphanedChildren?: (pids: readonly number[]) => Promise<unknown>;
21
28
  readonly parallelPoolWidth: number;
22
29
  readonly onMessage?: (message: KernelToHostMessage) => void;
23
30
  readonly workerEntryUrl?: URL;
@@ -27,6 +34,12 @@ export interface JavaScriptKernelOptions {
27
34
  readonly hostToolNames?: KernelToolNameSource;
28
35
  /** Tool names registered in another kernel language, denied as JS kernel-tool identifiers. */
29
36
  readonly foreignLanguageNames?: KernelToolNameSource;
37
+ /** Post-cell collection, notice, and ceiling thresholds; absent leaves the worker's memory unmanaged. */
38
+ readonly memory?: KernelMemoryThresholds;
39
+ /** Live heap bytes measured by an idle collection the worker ran between cells. */
40
+ readonly onMemoryCollected?: (liveBytes: number) => void;
41
+ /** Interrupt acknowledgement, settle grace, and worker-termination deadlines; absent uses `DEFAULT_INTERRUPT_BOUNDS`. */
42
+ readonly interruptBounds?: JavaScriptInterruptBounds;
30
43
  }
31
44
 
32
45
  export type JavaScriptRunInput = EvalKernelRunInput;
@@ -2,12 +2,14 @@ import { kernelToolCallContext } from "./kernel-tools-context.js";
2
2
  import { kernelToolError } from "./kernel-tools-errors.js";
3
3
  import { createKernelToolPump } from "./kernel-tools-pump.js";
4
4
  import { hostDeniedError, hostToolRefusal } from "./kernel-tools-scope.js";
5
+ import { createWorkerMemory } from "./worker-memory.js";
5
6
  import { JsWorkerRuntime } from "./worker-runtime.js";
6
7
  import { installKernelWebView } from "./worker-webview.js";
7
8
 
8
- // Mirrors INTERRUPT_ACK_OP and CHILD_LIFECYCLE_OP in src/bridge/reserved.ts (this worker file cannot import TypeScript).
9
+ // Mirrors INTERRUPT_ACK_OP, CHILD_LIFECYCLE_OP, and MEMORY_COLLECTED_OP in src/bridge/reserved.ts (this worker file cannot import TypeScript).
9
10
  const INTERRUPT_ACK_OP = "interrupt-ack";
10
11
  const CHILD_LIFECYCLE_OP = "child";
12
+ const MEMORY_COLLECTED_OP = "memory-collected";
11
13
 
12
14
  // Mirrors SESSION_ENVIRONMENT_KEYS in src/kernels/session-env.ts (this worker file
13
15
  // cannot import TypeScript). Keys the active session does not set must be dropped so a
@@ -24,6 +26,7 @@ const SESSION_ENVIRONMENT_KEYS = [
24
26
 
25
27
  export function createWorkerCore(transport, options) {
26
28
  let runtime = null;
29
+ let memory = null;
27
30
  let activeCell = null;
28
31
  const pendingTools = new Map();
29
32
  const pendingWebViewPorts = new Map();
@@ -50,14 +53,18 @@ export function createWorkerCore(transport, options) {
50
53
  emit,
51
54
  callTool: async (toolName, args) => await callTool(toolName, args),
52
55
  });
53
- emit({ type: "result", cellId: message.cellId, ok: true, valueRepr: valueRepr(value), durationMs: durationMs(startedAtMs) });
56
+ emit({ type: "result", cellId: message.cellId, ok: true, valueRepr: valueRepr(value), durationMs: durationMs(startedAtMs), ...memoryReport() });
54
57
  } catch (error) {
55
- emit({ type: "result", cellId: message.cellId, ok: false, error: bridgeError(error), durationMs: durationMs(startedAtMs) });
58
+ emit({ type: "result", cellId: message.cellId, ok: false, error: bridgeError(error), durationMs: durationMs(startedAtMs), ...memoryReport() });
56
59
  } finally {
57
60
  activeCell = null;
58
61
  }
59
62
  }
60
63
 
64
+ function memoryReport() {
65
+ return memory === null ? {} : { memory: memory.afterCell() };
66
+ }
67
+
61
68
  async function callTool(toolName, args) {
62
69
  const nested = kernelToolCallContext.getStore();
63
70
  if (!nested && activeCell?.interruption) throw activeCell.interruption;
@@ -96,6 +103,7 @@ export function createWorkerCore(transport, options) {
96
103
  }
97
104
 
98
105
  function onMessage(message) {
106
+ if (message.type === "run" || message.type === "interrupt" || message.type === "close") memory?.cancelIdle();
99
107
  if (kernelTools.handle(message)) return;
100
108
  if (message.type === "kernel-tools-names") {
101
109
  runtime?.kernelTools.setCollisionNames(message.hostToolNames ?? [], message.foreignLanguageNames ?? []);
@@ -121,6 +129,10 @@ export function createWorkerCore(transport, options) {
121
129
  foreignLanguageNames: message.foreignLanguageNames ?? [],
122
130
  onChildEvent: (event) => emit({ type: "status", event: { op: CHILD_LIFECYCLE_OP, ...event } }),
123
131
  });
132
+ if (message.memory) {
133
+ memory = createWorkerMemory(message.memory, (report) => emit({ type: "status", event: { op: MEMORY_COLLECTED_OP, ...report } }));
134
+ memory.captureBaseline();
135
+ }
124
136
  emit({ type: "ready" });
125
137
  return;
126
138
  }
@@ -0,0 +1,123 @@
1
+ import { types } from "node:util";
2
+
3
+ const SAMPLED_ELEMENTS = 1_000;
4
+ const NODE_BUDGET = 200_000;
5
+ const MAX_DEPTH = 64;
6
+ const POINTER_BYTES = 8;
7
+ const OBJECT_BYTES = 16;
8
+ const FUNCTION_BYTES = 64;
9
+ const INTERNAL_GLOBAL_PREFIX = "__senpi";
10
+ const MIN_REPORTED_BYTES = 1024 * 1024;
11
+
12
+ export function captureGlobalBaseline() {
13
+ return new Set(Object.getOwnPropertyNames(globalThis));
14
+ }
15
+
16
+ /**
17
+ * Estimated retained size of each user global, largest first. Collections are sized from their length and
18
+ * a sample of up to 1,000 evenly spaced elements, so a huge array is not under-reported; a shared visited
19
+ * set counts shared objects once, and one node budget bounds the whole walk. A sampled or cut-short
20
+ * estimate is marked `approximate`. Accessors and proxies are never invoked.
21
+ */
22
+ export function largestGlobals(baseline, limit) {
23
+ const sizer = createSizer();
24
+ const sized = [];
25
+ for (const name of Object.getOwnPropertyNames(globalThis)) {
26
+ if (baseline.has(name) || name.startsWith(INTERNAL_GLOBAL_PREFIX)) continue;
27
+ const descriptor = Object.getOwnPropertyDescriptor(globalThis, name);
28
+ if (descriptor === undefined || !("value" in descriptor)) continue;
29
+ const measured = sizer.measure(descriptor.value);
30
+ if (measured === undefined) continue;
31
+ const { bytes, approximate } = measured;
32
+ sized.push({ name, bytes: Math.round(bytes), ...(approximate ? { approximate: true } : {}) });
33
+ }
34
+ return sized
35
+ .filter((global) => global.bytes >= MIN_REPORTED_BYTES)
36
+ .sort((left, right) => right.bytes - left.bytes)
37
+ .slice(0, limit);
38
+ }
39
+
40
+ function createSizer() {
41
+ const seen = new WeakSet();
42
+ let nodes = 0;
43
+ let approximate = false;
44
+
45
+ function sampled(length, at, depth) {
46
+ if (length <= SAMPLED_ELEMENTS) {
47
+ let total = 0;
48
+ for (let index = 0; index < length; index += 1) total += size(at(index), depth);
49
+ return total;
50
+ }
51
+ approximate = true;
52
+ const step = length / SAMPLED_ELEMENTS;
53
+ let total = 0;
54
+ for (let sample = 0; sample < SAMPLED_ELEMENTS; sample += 1) total += size(at(Math.floor(sample * step)), depth);
55
+ return (total / SAMPLED_ELEMENTS) * length;
56
+ }
57
+
58
+ function entriesOf(iterable, count, depth, sizeOf) {
59
+ const taken = [];
60
+ for (const entry of iterable) {
61
+ taken.push(entry);
62
+ if (taken.length >= SAMPLED_ELEMENTS) break;
63
+ }
64
+ if (taken.length < count) approximate = true;
65
+ let total = 0;
66
+ for (const entry of taken) total += sizeOf(entry, depth);
67
+ return total * (count / Math.max(1, taken.length));
68
+ }
69
+
70
+ function objectSize(value, depth) {
71
+ if (types.isProxy(value)) return OBJECT_BYTES;
72
+ if (ArrayBuffer.isView(value)) return value.byteLength;
73
+ if (types.isAnyArrayBuffer(value)) return value.byteLength;
74
+ if (typeof Blob === "function" && value instanceof Blob) return value.size;
75
+ if (Array.isArray(value)) return OBJECT_BYTES + value.length * POINTER_BYTES + sampled(value.length, (index) => value[index], depth);
76
+ if (value instanceof Map) {
77
+ const entries = Map.prototype.entries.call(value);
78
+ return OBJECT_BYTES + value.size * 2 * POINTER_BYTES + entriesOf(entries, value.size, depth, ([key, item], at) => size(key, at) + size(item, at));
79
+ }
80
+ if (value instanceof Set) return OBJECT_BYTES + value.size * POINTER_BYTES + entriesOf(Set.prototype.values.call(value), value.size, depth, size);
81
+ const keys = Object.keys(value);
82
+ const propertyValue = (index) => {
83
+ const descriptor = Object.getOwnPropertyDescriptor(value, keys[index]);
84
+ return descriptor !== undefined && "value" in descriptor ? descriptor.value : undefined;
85
+ };
86
+ return OBJECT_BYTES + keys.length * POINTER_BYTES + sampled(keys.length, propertyValue, depth);
87
+ }
88
+
89
+ // Bytes a value owns beyond the pointer-sized slot its container already charged for it.
90
+ function size(value, depth) {
91
+ switch (typeof value) {
92
+ case "string":
93
+ return OBJECT_BYTES + value.length * 2;
94
+ case "function":
95
+ return FUNCTION_BYTES;
96
+ case "object":
97
+ break;
98
+ default:
99
+ return 0;
100
+ }
101
+ if (value === null || seen.has(value)) return 0;
102
+ if (depth >= MAX_DEPTH || nodes >= NODE_BUDGET) {
103
+ approximate = true;
104
+ return 0;
105
+ }
106
+ seen.add(value);
107
+ nodes += 1;
108
+ return objectSize(value, depth + 1);
109
+ }
110
+
111
+ return {
112
+ measure(value) {
113
+ approximate = false;
114
+ try {
115
+ return { bytes: POINTER_BYTES + size(value, 0), approximate };
116
+ } catch (error) {
117
+ // A user value can still throw from an exotic element read (an index getter); it is left unsized.
118
+ if (error instanceof Error) return undefined;
119
+ throw error;
120
+ }
121
+ },
122
+ };
123
+ }
@@ -0,0 +1,59 @@
1
+ import { getHeapStatistics, setFlagsFromString } from "node:v8";
2
+ import { runInNewContext } from "node:vm";
3
+
4
+ // Bun 1.4.3 ships its own idle collections (oven-sh/bun#43174, #43681), run by every JS thread including
5
+ // Workers; the kernel's idle collection only fills that gap on older Bun and must not compete with it.
6
+ const BUN_WITHOUT_IDLE_COLLECTIONS = "<1.4.3";
7
+
8
+ /**
9
+ * Reads and collects this worker's own heap (a worker thread owns its VM on both runtimes).
10
+ *
11
+ * Bun: `heapStats()` refreshes only at collections and walks the whole heap, and `Bun.gc(false)` only
12
+ * requests an asynchronous collection, so the estimate runs a synchronous eden collection (`edenGC()`, a
13
+ * few ms) that accounts fresh ArrayBuffers, then reads `heapSize()` (it includes `extraMemorySize`). Node: the isolate's used heap plus external memory (ArrayBuffer backing stores); `Worker`
14
+ * rejects `--expose-gc` in `execArgv`, so the gc function comes from one new context created while the
15
+ * flag is briefly on, and the second call releases backing stores V8 frees one cycle late.
16
+ */
17
+ export function createHeapProbe() {
18
+ const jsc = typeof globalThis.Bun?.gc === "function" ? process.getBuiltinModule?.("bun:jsc") : undefined;
19
+ if (jsc !== undefined && typeof jsc.heapSize === "function" && typeof jsc.edenGC === "function") {
20
+ return {
21
+ canCollect: true,
22
+ idleCollection: globalThis.Bun.semver.satisfies(globalThis.Bun.version, BUN_WITHOUT_IDLE_COLLECTIONS),
23
+ estimate() {
24
+ jsc.edenGC();
25
+ return jsc.heapSize();
26
+ },
27
+ collect() {
28
+ globalThis.Bun.gc(true);
29
+ return jsc.heapSize();
30
+ },
31
+ };
32
+ }
33
+ const gc = exposedGc();
34
+ const read = () => {
35
+ const stats = getHeapStatistics();
36
+ return stats.used_heap_size + (stats.external_memory ?? 0);
37
+ };
38
+ return {
39
+ canCollect: gc !== undefined,
40
+ idleCollection: gc !== undefined,
41
+ estimate: read,
42
+ collect() {
43
+ gc?.();
44
+ gc?.();
45
+ return read();
46
+ },
47
+ };
48
+ }
49
+
50
+ function exposedGc() {
51
+ if (typeof globalThis.gc === "function") return globalThis.gc;
52
+ setFlagsFromString("--expose-gc");
53
+ try {
54
+ const gc = runInNewContext("typeof gc === 'function' ? gc : undefined");
55
+ return typeof gc === "function" ? gc : undefined;
56
+ } finally {
57
+ setFlagsFromString("--no-expose-gc");
58
+ }
59
+ }
@@ -0,0 +1,94 @@
1
+ import { captureGlobalBaseline, largestGlobals } from "./worker-global-sizes.js";
2
+ import { createHeapProbe } from "./worker-heap.js";
3
+
4
+ const MIB = 1024 * 1024;
5
+ const MIN_GROWTH_BYTES = 64 * MIB;
6
+ const GROWTH_RATIO = 0.25;
7
+ const NOTICE_GROWTH_RATIO = 1.25;
8
+ const IDLE_COLLECT_DELAY_MS = 1_000;
9
+ // A full collection costs at most 1/20 of the time since the previous one.
10
+ const COLLECT_RATE_FLOOR = 20;
11
+ const REPORTED_GLOBALS = 5;
12
+
13
+ /**
14
+ * Post-cell memory policy of one JS kernel worker (thresholds come from `init`):
15
+ * - a settled cell whose heap estimate reached the ceiling, grew past the watermark by more than
16
+ * max(64 MiB, 25%) since the last measurement, or crossed the notice line upward (or grew 25% past it)
17
+ * gets a synchronous full collection before its result is sent, and the result reports the live size;
18
+ * - after any cell that leaves at least the watermark live, an idle full collection runs about a second
19
+ * later unless the next run, interrupt, or close arrives first: a cell that only drops a global
20
+ * allocates nothing, so nothing else would ever notice the garbage. Runtimes that collect idle
21
+ * threads themselves (Bun 1.4.3+) skip this; the post-cell measurement above still runs everywhere.
22
+ */
23
+ export function createWorkerMemory(thresholds, onIdleCollected, { heap = createHeapProbe(), now = () => performance.now() } = {}) {
24
+ let baseline = new Set();
25
+ let lastLive = 0;
26
+ let noticeRef = 0;
27
+ let lastCollectEnd = Number.NEGATIVE_INFINITY;
28
+ let lastCollectMs = 0;
29
+ let idleTimer = null;
30
+
31
+ function collect() {
32
+ const startedAt = now();
33
+ const live = heap.collect();
34
+ lastCollectEnd = now();
35
+ lastCollectMs = lastCollectEnd - startedAt;
36
+ lastLive = live;
37
+ if (live < thresholds.noticeBytes / 2) noticeRef = 0;
38
+ return live;
39
+ }
40
+
41
+ function needsCollection(estimate) {
42
+ const { gcWatermarkBytes, noticeBytes, ceilingBytes } = thresholds;
43
+ if (ceilingBytes > 0 && estimate >= ceilingBytes) return true;
44
+ if (gcWatermarkBytes > 0 && estimate >= gcWatermarkBytes) {
45
+ if (estimate > lastLive + Math.max(MIN_GROWTH_BYTES, lastLive * GROWTH_RATIO)) return true;
46
+ }
47
+ if (noticeBytes === 0 || estimate < noticeBytes) return false;
48
+ return lastLive < noticeBytes || noticeRef === 0 || estimate >= noticeRef * NOTICE_GROWTH_RATIO;
49
+ }
50
+
51
+ function worthNaming(live) {
52
+ const { noticeBytes, ceilingBytes } = thresholds;
53
+ return (noticeBytes > 0 && live >= noticeBytes) || (ceilingBytes > 0 && live >= ceilingBytes);
54
+ }
55
+
56
+ function cancelIdle() {
57
+ if (idleTimer !== null) clearTimeout(idleTimer);
58
+ idleTimer = null;
59
+ }
60
+
61
+ function scheduleIdle() {
62
+ if (!heap.idleCollection || thresholds.gcWatermarkBytes === 0 || lastLive < thresholds.gcWatermarkBytes) return;
63
+ const floorEnd = lastCollectEnd + COLLECT_RATE_FLOOR * lastCollectMs;
64
+ const delayMs = Math.max(IDLE_COLLECT_DELAY_MS, floorEnd - now());
65
+ idleTimer = setTimeout(() => {
66
+ idleTimer = null;
67
+ onIdleCollected({ liveBytes: Math.round(collect()), measure: "heap" });
68
+ }, delayMs);
69
+ idleTimer.unref?.();
70
+ }
71
+
72
+ return {
73
+ captureBaseline() {
74
+ baseline = captureGlobalBaseline();
75
+ },
76
+ cancelIdle,
77
+ afterCell() {
78
+ cancelIdle();
79
+ let liveBytes = heap.estimate();
80
+ const gcRan = heap.canCollect && needsCollection(liveBytes);
81
+ if (gcRan) liveBytes = collect();
82
+ const noticeLevel = gcRan && thresholds.noticeBytes > 0 && liveBytes >= thresholds.noticeBytes;
83
+ if (noticeLevel && (noticeRef === 0 || liveBytes >= noticeRef * NOTICE_GROWTH_RATIO)) noticeRef = liveBytes;
84
+ const globals = gcRan && worthNaming(liveBytes) ? largestGlobals(baseline, REPORTED_GLOBALS) : [];
85
+ scheduleIdle();
86
+ return {
87
+ liveBytes: Math.round(liveBytes),
88
+ measure: "heap",
89
+ ...(gcRan ? { gcRan: true } : {}),
90
+ ...(globals.length > 0 ? { globals } : {}),
91
+ };
92
+ },
93
+ };
94
+ }
@@ -13,6 +13,10 @@ import { kernelToolError } from "./kernel-tools-errors.js";
13
13
  import { createKernelToolRegistry, createToolNamespace } from "./kernel-tools-registry.js";
14
14
 
15
15
  const PREPARED_CELL_PREFIX = "/*senpi:prepared-cell*/";
16
+ // One stable source URL for the loader/contribution prelude: a per-cell URL makes
17
+ // every cell a distinct eval source string, so the code cache gains one entry
18
+ // per cell for text that is identical until contributions change.
19
+ const PRELUDE_SOURCE_URL = "senpi:kernel-prelude";
16
20
  // How long a child gets to honour SIGTERM before SIGKILL. Short, because the
17
21
  // cell has already produced its value and the caller is waiting on settle.
18
22
  const CHILD_TERMINATION_GRACE_MS = 1_000;
@@ -57,7 +61,7 @@ export class JsWorkerRuntime {
57
61
  if (!isPlainObject(prepared) || typeof prepared.prelude !== "string" || typeof prepared.code !== "string") throw new Error("Invalid prepared JavaScript cell payload");
58
62
  ({ prelude, code: cellCode } = prepared);
59
63
  }
60
- if (prelude) indirectEval(prelude, `${cellId}:prelude`);
64
+ if (prelude) indirectEval(prelude, PRELUDE_SOURCE_URL);
61
65
  const value = await awaitMaybePromise(indirectEval(bindKernelBun(wrapUserCode(cellCode)), cellId));
62
66
  await this.#drainPendingDisplays();
63
67
  return value;
@@ -95,7 +95,9 @@ export class WorkerSlot {
95
95
  const webViews = this.#webViews;
96
96
  this.#worker = null;
97
97
  this.#webViews = null;
98
- const retirement = worker ? await retireWorker(worker) : "terminated";
98
+ const retirement = worker
99
+ ? await retireWorker(worker, this.#options.interruptBounds?.terminateDeadlineMs)
100
+ : "terminated";
99
101
  await webViews?.release();
100
102
  return retirement;
101
103
  }
@@ -67,6 +67,7 @@ async function initializeWorker(worker: WorkerLike, hooks: WorkerStartupHooks, s
67
67
  hostToolNames: resolveKernelToolNameSource(options.hostToolNames),
68
68
  foreignLanguageNames: resolveKernelToolNameSource(options.foreignLanguageNames),
69
69
  ...(options.sessionEnv === undefined ? {} : { sessionEnv: options.sessionEnv }),
70
+ ...(options.memory === undefined ? {} : { memory: options.memory }),
70
71
  });
71
72
  await ready;
72
73
  }
@@ -1,3 +1,4 @@
1
+ import type { KernelMemoryThresholds } from "../../bridge/memory-protocol.ts";
1
2
  import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/protocol.ts";
2
3
  import type { EvalKernelRunInput } from "../../tool/types.ts";
3
4
  import type { SessionEnvironment } from "../session-env.ts";
@@ -15,6 +16,8 @@ export interface PythonKernelStartOptions {
15
16
  readonly startupTimeoutMs?: number;
16
17
  readonly onMessage?: (message: KernelToHostMessage) => void;
17
18
  readonly spawnProcess?: KernelSpawnProcess;
19
+ /** Post-cell collection, notice, and ceiling thresholds sent on `init`; absent leaves memory unmanaged. */
20
+ readonly memory?: KernelMemoryThresholds;
18
21
  }
19
22
 
20
23
  export type PythonKernelRunOptions = EvalKernelRunInput;
@@ -1,6 +1,7 @@
1
1
  import type { KernelInterruptHandle } from "../../tool/types.ts";
2
2
  import type { KernelToolsInvokeOptions } from "../js/kernel-tools-types.ts";
3
3
  import { rejectKernelToolsUnavailable } from "../kernel-tools-unavailable.ts";
4
+ import { KernelMemoryHost } from "../shared/kernel-memory-host.ts";
4
5
  import { KernelPreludeTracker } from "../shared/kernel-prelude-plan.ts";
5
6
  import type { PendingRun, PythonKernelRunOptions, PythonKernelStartOptions, ResultMessage } from "./kernel-contract.ts";
6
7
  import { failedPythonResult, PythonKernelTransport } from "./transport.ts";
@@ -22,11 +23,13 @@ export class PythonKernel {
22
23
  #closePromise: Promise<void> | null = null;
23
24
  #failure: Error | null = null;
24
25
  readonly #preludes = new KernelPreludeTracker();
26
+ readonly #memory: KernelMemoryHost | null;
25
27
  #generation = 0;
26
28
  #closed = false;
27
29
 
28
30
  private constructor(options: PythonKernelStartOptions) {
29
31
  this.#options = options;
32
+ this.#memory = options.memory === undefined ? null : new KernelMemoryHost("py", options.memory);
30
33
  }
31
34
 
32
35
  static async start(options: PythonKernelStartOptions): Promise<PythonKernel> {
@@ -215,6 +218,7 @@ export class PythonKernel {
215
218
 
216
219
  async #spawn(generation: number): Promise<void> {
217
220
  if (this.#closed || generation !== this.#generation) throw new Error("Python kernel startup was superseded");
221
+ this.#memory?.processReplaced();
218
222
  this.#transport = await PythonKernelTransport.start({
219
223
  ...this.#options,
220
224
  onMessage: (message) => {
@@ -241,11 +245,20 @@ export class PythonKernel {
241
245
  if (this.#transport !== transport) return;
242
246
  const pending = this.#pending.get(result.cellId);
243
247
  if (pending) {
244
- (pending.input.onMessage ?? this.#options.onMessage)?.(result);
245
- this.#settleRun(pending, result);
248
+ const annotated = this.#memory?.annotate(result) ?? result;
249
+ (pending.input.onMessage ?? this.#options.onMessage)?.(annotated);
250
+ this.#settleRun(pending, annotated);
246
251
  }
247
252
  // A result frame from the live runner proves the process survived the interrupt.
248
253
  if (pending?.resolveStateRetained) pending.resolveStateRetained(true);
254
+ this.#recycleOverCeilingWhenIdle();
255
+ }
256
+
257
+ /** An over-ceiling kernel restarts only once no cell is running or queued on it. */
258
+ #recycleOverCeilingWhenIdle(): void {
259
+ if (!this.#memory?.claimRecycle(this.#active === null && this.#queue.length === 0)) return;
260
+ // A failed restart is recorded in #failure and rejects the next run; a superseded one lost to close/reset.
261
+ void this.reset().catch(() => undefined);
249
262
  }
250
263
 
251
264
  #onExit(transport: PythonKernelTransport, error: Error): void {