@code-yeongyu/senpi-codemode 2026.9.28-3 → 2026.9.28-5
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 +37 -0
- package/README.md +13 -1
- package/package.json +4 -4
- package/src/bridge/memory-protocol.ts +39 -0
- package/src/bridge/protocol.ts +4 -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/interrupt-bounds.ts +8 -2
- package/src/kernels/js/kernel-contract.ts +13 -0
- package/src/kernels/js/worker-core.js +15 -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-memory.js +94 -0
- package/src/kernels/js/worker-runtime.js +5 -1
- package/src/kernels/js/worker-slot.ts +3 -1
- package/src/kernels/js/worker-startup.ts +1 -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/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/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
|
@@ -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 {
|
|
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")
|
|
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"
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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,
|
|
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
|
|
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;
|
package/src/kernels/py/kernel.ts
CHANGED
|
@@ -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
|
-
|
|
245
|
-
|
|
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 {
|