@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
|
@@ -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
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { dirname, join } from "node:path";
|
|
2
2
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
3
|
+
import type { MessagePort } from "node:worker_threads";
|
|
3
4
|
import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
4
5
|
import { type CodemodeRuntimeAssetEnvironment, requireCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
|
|
5
6
|
import type { JavaScriptKernelMode } from "./kernel-contract.ts";
|
|
@@ -18,7 +19,7 @@ export function resolveInlineWorkerEntryUrl(options: JavaScriptInlineWorkerEntry
|
|
|
18
19
|
|
|
19
20
|
export interface WorkerLike {
|
|
20
21
|
readonly mode: JavaScriptKernelMode;
|
|
21
|
-
postMessage(message: HostToKernelMessage): void;
|
|
22
|
+
postMessage(message: HostToKernelMessage, transfer?: readonly MessagePort[]): void;
|
|
22
23
|
onMessage(handler: (message: KernelToHostMessage) => void): () => void;
|
|
23
24
|
onError(handler: (error: Error) => void): () => void;
|
|
24
25
|
terminate(): Promise<void>;
|
|
@@ -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;
|
|
@@ -77,6 +77,8 @@ function loaderPrelude(context: RuntimeModuleContext): string {
|
|
|
77
77
|
"globalThis.__senpi_import__ = async (source, options) => {",
|
|
78
78
|
" const context = globalThis.__senpi_module_context__;",
|
|
79
79
|
" const specifier = String(source);",
|
|
80
|
+
" const kernelBun = globalThis[Symbol.for('senpi.kernel.bun')];",
|
|
81
|
+
" if (specifier === 'bun' && kernelBun) return { ...(await import('bun')), WebView: kernelBun.WebView, default: kernelBun };",
|
|
80
82
|
" const match = /^([a-z][a-z0-9+.-]*):\\/\\/(.*)$/i.exec(specifier);",
|
|
81
83
|
" let target = specifier;",
|
|
82
84
|
" if (match) {",
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { MessagePort } from "node:worker_threads";
|
|
2
|
+
import type { WebViewServiceConnection } from "@code-yeongyu/senpi";
|
|
3
|
+
import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
4
|
+
import { bridgeError } from "./worker-host.ts";
|
|
5
|
+
|
|
6
|
+
export type WebViewConnector = () => Promise<WebViewServiceConnection>;
|
|
7
|
+
|
|
8
|
+
// Loaded on the first WebView a cell asks for, so starting a kernel never pays for the host barrel.
|
|
9
|
+
const connectThroughHost: WebViewConnector = async () => (await import("@code-yeongyu/senpi")).connectWebViewService();
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The WebView clients one worker generation asked for. Chrome-backed `Bun.WebView`s only run on the
|
|
13
|
+
* process main thread, so the worker gets a private port to the main-thread service; retiring the
|
|
14
|
+
* generation (reset, close, crash, timeout) releases every view created through those ports.
|
|
15
|
+
*/
|
|
16
|
+
export class KernelWebViewClients {
|
|
17
|
+
readonly #post: (message: HostToKernelMessage, transfer: readonly MessagePort[]) => void;
|
|
18
|
+
readonly #connect: WebViewConnector;
|
|
19
|
+
readonly #connections = new Set<WebViewServiceConnection>();
|
|
20
|
+
#retired = false;
|
|
21
|
+
|
|
22
|
+
constructor(
|
|
23
|
+
post: (message: HostToKernelMessage, transfer: readonly MessagePort[]) => void,
|
|
24
|
+
connect: WebViewConnector = connectThroughHost,
|
|
25
|
+
) {
|
|
26
|
+
this.#post = post;
|
|
27
|
+
this.#connect = connect;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
consume(message: KernelToHostMessage): boolean {
|
|
31
|
+
if (message.type !== "webview-connect") return false;
|
|
32
|
+
void this.#grant(message.requestId);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async release(): Promise<void> {
|
|
37
|
+
this.#retired = true;
|
|
38
|
+
const connections = [...this.#connections];
|
|
39
|
+
this.#connections.clear();
|
|
40
|
+
await Promise.allSettled(connections.map((connection) => connection.release()));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async #grant(requestId: string): Promise<void> {
|
|
44
|
+
let connection: WebViewServiceConnection;
|
|
45
|
+
try {
|
|
46
|
+
connection = await this.#connect();
|
|
47
|
+
} catch (error) {
|
|
48
|
+
const cause = error instanceof Error ? error : new Error(String(error));
|
|
49
|
+
if (!this.#retired) this.#post({ type: "webview-port", requestId, ok: false, error: bridgeError(cause) }, []);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (this.#retired) {
|
|
53
|
+
await connection.release();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
this.#connections.add(connection);
|
|
57
|
+
this.#post({ type: "webview-port", requestId, ok: true, port: connection.port }, [connection.port]);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -2,11 +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";
|
|
7
|
+
import { installKernelWebView } from "./worker-webview.js";
|
|
6
8
|
|
|
7
|
-
// 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).
|
|
8
10
|
const INTERRUPT_ACK_OP = "interrupt-ack";
|
|
9
11
|
const CHILD_LIFECYCLE_OP = "child";
|
|
12
|
+
const MEMORY_COLLECTED_OP = "memory-collected";
|
|
10
13
|
|
|
11
14
|
// Mirrors SESSION_ENVIRONMENT_KEYS in src/kernels/session-env.ts (this worker file
|
|
12
15
|
// cannot import TypeScript). Keys the active session does not set must be dropped so a
|
|
@@ -23,8 +26,10 @@ const SESSION_ENVIRONMENT_KEYS = [
|
|
|
23
26
|
|
|
24
27
|
export function createWorkerCore(transport, options) {
|
|
25
28
|
let runtime = null;
|
|
29
|
+
let memory = null;
|
|
26
30
|
let activeCell = null;
|
|
27
31
|
const pendingTools = new Map();
|
|
32
|
+
const pendingWebViewPorts = new Map();
|
|
28
33
|
const nestedInvokes = new Map();
|
|
29
34
|
const kernelTools = createKernelToolPump({
|
|
30
35
|
getRuntime: () => runtime,
|
|
@@ -48,14 +53,18 @@ export function createWorkerCore(transport, options) {
|
|
|
48
53
|
emit,
|
|
49
54
|
callTool: async (toolName, args) => await callTool(toolName, args),
|
|
50
55
|
});
|
|
51
|
-
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() });
|
|
52
57
|
} catch (error) {
|
|
53
|
-
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() });
|
|
54
59
|
} finally {
|
|
55
60
|
activeCell = null;
|
|
56
61
|
}
|
|
57
62
|
}
|
|
58
63
|
|
|
64
|
+
function memoryReport() {
|
|
65
|
+
return memory === null ? {} : { memory: memory.afterCell() };
|
|
66
|
+
}
|
|
67
|
+
|
|
59
68
|
async function callTool(toolName, args) {
|
|
60
69
|
const nested = kernelToolCallContext.getStore();
|
|
61
70
|
if (!nested && activeCell?.interruption) throw activeCell.interruption;
|
|
@@ -73,6 +82,13 @@ export function createWorkerCore(transport, options) {
|
|
|
73
82
|
return await promise;
|
|
74
83
|
}
|
|
75
84
|
|
|
85
|
+
function requestWebViewPort() {
|
|
86
|
+
const requestId = crypto.randomUUID();
|
|
87
|
+
const promise = new Promise((resolve, reject) => pendingWebViewPorts.set(requestId, { resolve, reject }));
|
|
88
|
+
emit({ type: "webview-connect", requestId });
|
|
89
|
+
return promise;
|
|
90
|
+
}
|
|
91
|
+
|
|
76
92
|
function interruptCell(reason) {
|
|
77
93
|
if (!activeCell || !runtime) return;
|
|
78
94
|
emit({ type: "status", event: { op: INTERRUPT_ACK_OP, cellId: activeCell.cellId } });
|
|
@@ -87,13 +103,22 @@ export function createWorkerCore(transport, options) {
|
|
|
87
103
|
}
|
|
88
104
|
|
|
89
105
|
function onMessage(message) {
|
|
106
|
+
if (message.type === "run" || message.type === "interrupt" || message.type === "close") memory?.cancelIdle();
|
|
90
107
|
if (kernelTools.handle(message)) return;
|
|
91
108
|
if (message.type === "kernel-tools-names") {
|
|
92
109
|
runtime?.kernelTools.setCollisionNames(message.hostToolNames ?? [], message.foreignLanguageNames ?? []);
|
|
93
110
|
return;
|
|
94
111
|
}
|
|
112
|
+
if (message.type === "webview-port") {
|
|
113
|
+
const pending = pendingWebViewPorts.get(message.requestId);
|
|
114
|
+
pendingWebViewPorts.delete(message.requestId);
|
|
115
|
+
if (message.ok) pending?.resolve(message.port);
|
|
116
|
+
else pending?.reject(errorFromBridge(message.error));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
95
119
|
if (message.type === "init") {
|
|
96
120
|
applySessionEnvironment(message.sessionEnv);
|
|
121
|
+
installKernelWebView(requestWebViewPort);
|
|
97
122
|
runtime = new JsWorkerRuntime({
|
|
98
123
|
cwd: options.cwd,
|
|
99
124
|
parallelPoolWidth: options.parallelPoolWidth,
|
|
@@ -104,6 +129,10 @@ export function createWorkerCore(transport, options) {
|
|
|
104
129
|
foreignLanguageNames: message.foreignLanguageNames ?? [],
|
|
105
130
|
onChildEvent: (event) => emit({ type: "status", event: { op: CHILD_LIFECYCLE_OP, ...event } }),
|
|
106
131
|
});
|
|
132
|
+
if (message.memory) {
|
|
133
|
+
memory = createWorkerMemory(message.memory, (report) => emit({ type: "status", event: { op: MEMORY_COLLECTED_OP, ...report } }));
|
|
134
|
+
memory.captureBaseline();
|
|
135
|
+
}
|
|
107
136
|
emit({ type: "ready" });
|
|
108
137
|
return;
|
|
109
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
|
+
}
|
|
@@ -88,7 +88,8 @@ export function bridgeError(error: Error): {
|
|
|
88
88
|
function wrapNodeWorker(worker: Worker, mode: JavaScriptKernelMode): WorkerLike {
|
|
89
89
|
return {
|
|
90
90
|
mode,
|
|
91
|
-
postMessage: (message) =>
|
|
91
|
+
postMessage: (message, transfer) =>
|
|
92
|
+
worker.postMessage(message, transfer === undefined ? undefined : [...transfer]),
|
|
92
93
|
onMessage(handler) {
|
|
93
94
|
const listener = (message: KernelToHostMessage): void => handler(message);
|
|
94
95
|
worker.on("message", listener);
|
|
@@ -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
|
+
}
|
|
@@ -6,12 +6,17 @@ import { encodeDisplayImage, resolveDisplayOps } from "./display-image.js";
|
|
|
6
6
|
import { terminateProcessTrees } from "./process-tree.js";
|
|
7
7
|
import { awaitMaybePromise, indirectEval, wrapUserCode } from "./worker-indirect-eval.js";
|
|
8
8
|
import { installShellCapture } from "./worker-shell-capture.js";
|
|
9
|
+
import { bindKernelBun } from "./worker-webview.js";
|
|
9
10
|
import { createWorkpool } from "./workpool.js";
|
|
10
11
|
import { inKernelToolInvoke } from "./kernel-tools-context.js";
|
|
11
12
|
import { kernelToolError } from "./kernel-tools-errors.js";
|
|
12
13
|
import { createKernelToolRegistry, createToolNamespace } from "./kernel-tools-registry.js";
|
|
13
14
|
|
|
14
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";
|
|
15
20
|
// How long a child gets to honour SIGTERM before SIGKILL. Short, because the
|
|
16
21
|
// cell has already produced its value and the caller is waiting on settle.
|
|
17
22
|
const CHILD_TERMINATION_GRACE_MS = 1_000;
|
|
@@ -56,8 +61,8 @@ export class JsWorkerRuntime {
|
|
|
56
61
|
if (!isPlainObject(prepared) || typeof prepared.prelude !== "string" || typeof prepared.code !== "string") throw new Error("Invalid prepared JavaScript cell payload");
|
|
57
62
|
({ prelude, code: cellCode } = prepared);
|
|
58
63
|
}
|
|
59
|
-
if (prelude) indirectEval(prelude,
|
|
60
|
-
const value = await awaitMaybePromise(indirectEval(wrapUserCode(cellCode), cellId));
|
|
64
|
+
if (prelude) indirectEval(prelude, PRELUDE_SOURCE_URL);
|
|
65
|
+
const value = await awaitMaybePromise(indirectEval(bindKernelBun(wrapUserCode(cellCode)), cellId));
|
|
61
66
|
await this.#drainPendingDisplays();
|
|
62
67
|
return value;
|
|
63
68
|
} finally {
|