@code-yeongyu/senpi-codemode 2026.10.10-9 → 2026.10.11
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 +72 -0
- package/package.json +4 -4
- package/src/bridge/memory-protocol.ts +8 -1
- package/src/bridge/protocol.ts +1 -1
- package/src/bridges/eval-environment-schemas.ts +5 -1
- package/src/environments/editable-check.ts +1 -1
- package/src/extension/session-cwd.ts +25 -9
- package/src/extension/session-manager.ts +4 -4
- package/src/kernels/jl/globals.jl +128 -0
- package/src/kernels/jl/prelude.jl +15 -2
- package/src/kernels/jl/runner.jl +35 -143
- package/src/kernels/js/group-signal-notice.js +117 -0
- package/src/kernels/js/inline-worker.ts +2 -0
- package/src/kernels/js/local-module-loader.ts +8 -0
- package/src/kernels/js/process-tree-host.ts +4 -8
- package/src/kernels/js/process-tree.d.ts +11 -0
- package/src/kernels/js/process-tree.js +55 -10
- package/src/kernels/js/windows-tree-kill-host.ts +117 -0
- package/src/kernels/js/windows-tree-kill.d.ts +19 -0
- package/src/kernels/js/windows-tree-kill.js +100 -0
- package/src/kernels/js/worker-cwd.js +41 -10
- package/src/kernels/js/worker-runtime.js +23 -4
- package/src/kernels/js/worker-shell-capture.js +4 -0
- package/src/kernels/js/worker-slot.ts +2 -3
- package/src/kernels/py/prelude.py +36 -25
- package/src/kernels/py/transport.ts +4 -0
- package/src/kernels/rb/runner.rb +9 -19
- package/src/kernels/sandbox/vendor/pi-codemode/runtime/host.ts +2 -2
- package/src/kernels/shared/bun-ffi.ts +24 -0
- package/src/kernels/shared/kernel-memory-host.ts +30 -7
- package/src/kernels/shared/process-cpu.ts +7 -3
- package/src/kernels/shared/process-group-cpu.ts +6 -3
- package/src/kernels/shared/subprocess-kernel.ts +29 -3
- package/src/kernels/shared/subprocess-memory-globals.ts +101 -0
- package/src/kernels/shared/subprocess-process.ts +20 -12
- package/src/kernels/shared/subprocess-queue.ts +1 -0
- package/src/tool/eval-tool.ts +2 -1
- package/src/tool/render-agent.ts +110 -0
- package/src/tool/render-blocks.ts +252 -0
- package/src/tool/render-call.ts +130 -0
- package/src/tool/render-cell.ts +165 -0
- package/src/tool/render-detail.ts +152 -0
- package/src/tool/render-live.ts +288 -0
- package/src/tool/render-result.ts +202 -0
- package/src/tool/render-status.ts +166 -0
- package/src/tool/render-tail.ts +139 -0
- package/src/tool/render.ts +3 -1128
- package/src/tool/run-eval-cell.ts +7 -12
- package/src/tool/types.ts +11 -2
|
@@ -5,8 +5,9 @@ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
|
5
5
|
import { dirname, isAbsolute, join, normalize, resolve, sep } from "node:path";
|
|
6
6
|
import { inspect } from "node:util";
|
|
7
7
|
import { encodeDisplayImage, resolveDisplayOps } from "./display-image.js";
|
|
8
|
-
import { terminateProcessTrees } from "./process-tree.js";
|
|
8
|
+
import { terminateProcessGroups, terminateProcessTrees } from "./process-tree.js";
|
|
9
9
|
import { awaitMaybePromise, indirectEval, wrapUserCode } from "./worker-indirect-eval.js";
|
|
10
|
+
import { INJECTED_GROUP } from "./worker-cwd.js";
|
|
10
11
|
import { installShellCapture } from "./worker-shell-capture.js";
|
|
11
12
|
import { bindKernelBun } from "./worker-webview.js";
|
|
12
13
|
import { createWorkpool } from "./workpool.js";
|
|
@@ -33,6 +34,9 @@ export class JsWorkerRuntime {
|
|
|
33
34
|
#hooks = null;
|
|
34
35
|
#pendingDisplays = [];
|
|
35
36
|
#children = new Set();
|
|
37
|
+
// Groups the worker created for cell children (senpi#2995). A group outlives its leader, so a grandchild re-parented
|
|
38
|
+
// to init is still found through it at retirement (senpi#3020).
|
|
39
|
+
#ownedGroups = new Map();
|
|
36
40
|
#childrenStopping;
|
|
37
41
|
#shellWaits = new Set();
|
|
38
42
|
#onChildEvent;
|
|
@@ -111,9 +115,14 @@ export class JsWorkerRuntime {
|
|
|
111
115
|
#trackChild(child, spawnOptions) {
|
|
112
116
|
if (child === null || typeof child !== "object" || typeof child.kill !== "function") return;
|
|
113
117
|
// `detached: true` is the cell saying it wants the process to outlive it.
|
|
114
|
-
|
|
118
|
+
const groupInjected = (globalThis[INJECTED_GROUP] ?? 0) > 0;
|
|
119
|
+
if (isPlainObject(spawnOptions) && spawnOptions.detached === true && !groupInjected) return;
|
|
115
120
|
this.#children.add(child);
|
|
116
121
|
const pid = Number.isInteger(child.pid) && child.pid > 0 ? child.pid : null;
|
|
122
|
+
// Bun.spawn children get their group from worker-cwd.js without the injection marker, so every tracked child is
|
|
123
|
+
// recorded; a child that is not a group leader names no group, and signalling it later finds nothing.
|
|
124
|
+
const ownGroupLeader = !(isPlainObject(spawnOptions) && spawnOptions.detached === false);
|
|
125
|
+
if (pid !== null && process.platform !== "win32" && ownGroupLeader) this.#ownedGroups.set(pid, child);
|
|
117
126
|
// The host keeps its own copy of live pids: if this worker is terminated
|
|
118
127
|
// while blocked, only the host can still retire them.
|
|
119
128
|
if (pid !== null) this.#onChildEvent?.({ pid, state: "spawned" });
|
|
@@ -127,10 +136,20 @@ export class JsWorkerRuntime {
|
|
|
127
136
|
#terminateChildren() {
|
|
128
137
|
const children = [...this.#children].filter(child => child.exitCode === null && child.signalCode === null);
|
|
129
138
|
this.#children.clear();
|
|
130
|
-
|
|
139
|
+
// A leader that already exited leaves its group id reserved only while the group has members; if that pid now
|
|
140
|
+
// names a live process, it was reused by someone else and the group is not ours to signal (senpi#3020 review).
|
|
141
|
+
const groups = [...this.#ownedGroups].map(([pgid, child]) => ({
|
|
142
|
+
pgid,
|
|
143
|
+
leaderExited: child.exitCode !== null || child.signalCode !== null,
|
|
144
|
+
}));
|
|
145
|
+
this.#ownedGroups.clear();
|
|
146
|
+
if (children.length === 0 && groups.length === 0) return undefined;
|
|
131
147
|
const roots = children.map(child => child.pid).filter(pid => Number.isInteger(pid) && pid > 0);
|
|
132
148
|
const settled = Promise.allSettled(children.map(child => (child.exited instanceof Promise ? child.exited : Promise.resolve())));
|
|
133
|
-
return
|
|
149
|
+
return Promise.all([
|
|
150
|
+
roots.length > 0 ? terminateProcessTrees(roots, { graceMs: CHILD_TERMINATION_GRACE_MS, settled }) : undefined,
|
|
151
|
+
terminateProcessGroups(groups, { graceMs: CHILD_TERMINATION_GRACE_MS }),
|
|
152
|
+
]);
|
|
134
153
|
}
|
|
135
154
|
|
|
136
155
|
async #drainPendingDisplays() {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { assertCellLive } from "./cell-run-context.js";
|
|
2
|
+
import { groupSignalNotice, noticeChildProcessGroupSignals, shellCommandText, signalsProcessGroup } from "./group-signal-notice.js";
|
|
2
3
|
|
|
3
4
|
const SHELL_CONFIG_METHODS = ["env", "cwd", "nothrow", "throws"];
|
|
4
5
|
const SHELL_READ_METHODS = ["text", "json", "lines", "arrayBuffer", "bytes", "blob"];
|
|
@@ -28,7 +29,9 @@ export function installShellCapture(options) {
|
|
|
28
29
|
bun.$ = capturedShell(originalShell, options);
|
|
29
30
|
bun.spawn = capturedSpawn(originalSpawn, options, pinEnv);
|
|
30
31
|
if (originalSpawnSync !== null) bun.spawnSync = capturedSpawnSync(originalSpawnSync, pinEnv);
|
|
32
|
+
const restoreChildProcessNotice = noticeChildProcessGroupSignals(options.emitText, options.isActive);
|
|
31
33
|
return () => {
|
|
34
|
+
restoreChildProcessNotice();
|
|
32
35
|
bun.$ = originalShell;
|
|
33
36
|
bun.spawn = originalSpawn;
|
|
34
37
|
if (originalSpawnSync !== null) bun.spawnSync = originalSpawnSync;
|
|
@@ -43,6 +46,7 @@ function capturedShell(originalShell, options) {
|
|
|
43
46
|
const shell = (strings, ...expressions) => {
|
|
44
47
|
assertCellLive();
|
|
45
48
|
if (!options.isActive()) return originalShell(strings, ...expressions);
|
|
49
|
+
if (signalsProcessGroup(shellCommandText(strings, expressions))) options.emitText("stderr", groupSignalNotice("Bun.$"));
|
|
46
50
|
const promise = originalShell(isolateStdin(strings), ...expressions);
|
|
47
51
|
return captureShellPromise(promise, options);
|
|
48
52
|
};
|
|
@@ -4,7 +4,6 @@ import type { WorkerLike } from "./inline-worker.ts";
|
|
|
4
4
|
import { retireWorker, type WorkerRetirement } from "./interrupt-bounds.ts";
|
|
5
5
|
import type { JavaScriptKernelMode } from "./kernel-contract.ts";
|
|
6
6
|
import type { JavaScriptKernelOptions } from "./local-module-loader.ts";
|
|
7
|
-
import type { JavaScriptProcessWorker } from "./process-worker.ts";
|
|
8
7
|
import { KernelWebViewClients } from "./webview-host.ts";
|
|
9
8
|
import { WorkerStartupCancelledError } from "./worker-host.ts";
|
|
10
9
|
import { startWorkerWithInlineFallback } from "./worker-startup.ts";
|
|
@@ -38,7 +37,7 @@ export class WorkerSlot {
|
|
|
38
37
|
|
|
39
38
|
get processPid(): number | undefined {
|
|
40
39
|
const worker = this.#worker;
|
|
41
|
-
if (worker?.mode === "process") return
|
|
40
|
+
if (worker?.mode === "process") return worker.pid;
|
|
42
41
|
return this.#lastProcessPid;
|
|
43
42
|
}
|
|
44
43
|
|
|
@@ -113,7 +112,7 @@ export class WorkerSlot {
|
|
|
113
112
|
#publish(worker: WorkerLike, generation: number): void {
|
|
114
113
|
if (!this.#listeners.isOpen() || generation !== this.#generation) throw new WorkerStartupCancelledError();
|
|
115
114
|
this.#worker = worker;
|
|
116
|
-
if (worker.mode === "process") this.#lastProcessPid =
|
|
115
|
+
if (worker.mode === "process") this.#lastProcessPid = worker.pid;
|
|
117
116
|
const webViews = new KernelWebViewClients((message, transfer) => worker.postMessage(message, transfer));
|
|
118
117
|
this.#webViews = webViews;
|
|
119
118
|
worker.onMessage((message) => {
|
|
@@ -661,25 +661,6 @@ class ThreadRoutedStream(io.TextIOBase):
|
|
|
661
661
|
)
|
|
662
662
|
self.cell_target: io.StringIO | None = None
|
|
663
663
|
|
|
664
|
-
@contextlib.contextmanager
|
|
665
|
-
def capture(self, buffer: io.StringIO, *, cell: bool) -> Iterator[None]:
|
|
666
|
-
me = threading.get_ident()
|
|
667
|
-
previous = self._targets.get(me)
|
|
668
|
-
self._targets[me] = buffer
|
|
669
|
-
inherited = self._inherited.set(buffer)
|
|
670
|
-
if cell:
|
|
671
|
-
self.cell_target = buffer
|
|
672
|
-
try:
|
|
673
|
-
yield
|
|
674
|
-
finally:
|
|
675
|
-
self._inherited.reset(inherited)
|
|
676
|
-
if previous is None:
|
|
677
|
-
self._targets.pop(me, None)
|
|
678
|
-
else:
|
|
679
|
-
self._targets[me] = previous
|
|
680
|
-
if cell:
|
|
681
|
-
self.cell_target = None
|
|
682
|
-
|
|
683
664
|
def write(self, text: str) -> int:
|
|
684
665
|
target = self._targets.get(threading.get_ident()) or self._inherited.get() or self.cell_target
|
|
685
666
|
if target is None:
|
|
@@ -694,6 +675,38 @@ class ThreadRoutedStream(io.TextIOBase):
|
|
|
694
675
|
return None
|
|
695
676
|
|
|
696
677
|
|
|
678
|
+
@contextlib.contextmanager
|
|
679
|
+
def capture_streams(
|
|
680
|
+
streams: tuple[ThreadRoutedStream, ThreadRoutedStream],
|
|
681
|
+
buffers: tuple[io.StringIO, io.StringIO],
|
|
682
|
+
*,
|
|
683
|
+
cell: bool,
|
|
684
|
+
) -> Iterator[None]:
|
|
685
|
+
stdout, stderr = streams
|
|
686
|
+
out, err = buffers
|
|
687
|
+
me = threading.get_ident()
|
|
688
|
+
previous_out, previous_err = stdout._targets.get(me), stderr._targets.get(me)
|
|
689
|
+
stdout._targets[me], stderr._targets[me] = out, err
|
|
690
|
+
inherited_out, inherited_err = stdout._inherited.set(out), stderr._inherited.set(err)
|
|
691
|
+
if cell:
|
|
692
|
+
stdout.cell_target, stderr.cell_target = out, err
|
|
693
|
+
try:
|
|
694
|
+
yield
|
|
695
|
+
finally:
|
|
696
|
+
stderr._inherited.reset(inherited_err)
|
|
697
|
+
stdout._inherited.reset(inherited_out)
|
|
698
|
+
if previous_err is None:
|
|
699
|
+
stderr._targets.pop(me, None)
|
|
700
|
+
else:
|
|
701
|
+
stderr._targets[me] = previous_err
|
|
702
|
+
if previous_out is None:
|
|
703
|
+
stdout._targets.pop(me, None)
|
|
704
|
+
else:
|
|
705
|
+
stdout._targets[me] = previous_out
|
|
706
|
+
if cell:
|
|
707
|
+
stdout.cell_target, stderr.cell_target = None, None
|
|
708
|
+
|
|
709
|
+
|
|
697
710
|
@dataclass
|
|
698
711
|
class _Entry:
|
|
699
712
|
name: str
|
|
@@ -905,8 +918,7 @@ class KernelToolRunner:
|
|
|
905
918
|
if invocation.cancelled.is_set():
|
|
906
919
|
raise Cancelled(CANCELLED_BEFORE_START)
|
|
907
920
|
CURRENT.set(invocation)
|
|
908
|
-
|
|
909
|
-
with stdout.capture(output, cell=False), stderr.capture(output, cell=False):
|
|
921
|
+
with capture_streams(self._streams, (output, output), cell=False):
|
|
910
922
|
value = self._call(invocation, entry.fn, args)
|
|
911
923
|
finally:
|
|
912
924
|
CURRENT.set(None)
|
|
@@ -2218,8 +2230,7 @@ def run_cell(
|
|
|
2218
2230
|
cell_scope = CURRENT_CELL_TOKEN.set(cell_token)
|
|
2219
2231
|
try:
|
|
2220
2232
|
KERNEL_TOOL_TOKEN.acquire()
|
|
2221
|
-
|
|
2222
|
-
with cell_stdout.capture(stdout, cell=True), cell_stderr.capture(stderr, cell=True):
|
|
2233
|
+
with capture_streams(KERNEL_TOOL_STREAMS, (stdout, stderr), cell=True):
|
|
2223
2234
|
apply_preludes(preludes)
|
|
2224
2235
|
leave_source_file = _enter_source_file(source_file)
|
|
2225
2236
|
body, expression = compile_cell(code, source_file or "<cell>")
|
|
@@ -2373,7 +2384,7 @@ def _start_parent_watch() -> None:
|
|
|
2373
2384
|
).start()
|
|
2374
2385
|
|
|
2375
2386
|
|
|
2376
|
-
def _read_control(commands: queue.
|
|
2387
|
+
def _read_control(commands: queue.SimpleQueue[tuple[str, Any]]) -> None:
|
|
2377
2388
|
# Kernel-tool frames are served here, so a callback can be admitted while the main thread is inside a cell.
|
|
2378
2389
|
for raw in sys.stdin:
|
|
2379
2390
|
try:
|
|
@@ -2392,7 +2403,7 @@ def main() -> None:
|
|
|
2392
2403
|
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
2393
2404
|
_start_parent_watch()
|
|
2394
2405
|
sys.stdout, sys.stderr = KERNEL_TOOL_STREAMS
|
|
2395
|
-
commands: queue.
|
|
2406
|
+
commands: queue.SimpleQueue[tuple[str, Any]] = queue.SimpleQueue()
|
|
2396
2407
|
threading.Thread(target=_read_control, args=(commands,), name="senpi-control-reader", daemon=True).start()
|
|
2397
2408
|
host_closed = False
|
|
2398
2409
|
while True:
|
|
@@ -244,6 +244,10 @@ export class PythonKernelTransport {
|
|
|
244
244
|
this.#child.stderr.on("data", onStderr);
|
|
245
245
|
this.#child.on("error", onError);
|
|
246
246
|
this.#child.on("exit", onExit);
|
|
247
|
+
// senpi#3016: a frame written while the interpreter is dying fails on the stdin stream (EPIPE), not on the child.
|
|
248
|
+
// #onError already ignores it once the child has exited or the transport is inactive, and the listener is never
|
|
249
|
+
// detached, so a late write failure is never unhandled.
|
|
250
|
+
this.#child.stdin.on("error", onError);
|
|
247
251
|
const { sessionId, connection, memory, kernelGeneration } = this.#options;
|
|
248
252
|
this.#write({
|
|
249
253
|
type: "init",
|
package/src/kernels/rb/runner.rb
CHANGED
|
@@ -9,6 +9,7 @@ $stdout.flush
|
|
|
9
9
|
$__senpi_binding = TOPLEVEL_BINDING
|
|
10
10
|
$__senpi_frame_mutex = Mutex.new
|
|
11
11
|
$__senpi_current_cell = nil
|
|
12
|
+
$__senpi_memory_cell = nil
|
|
12
13
|
$__senpi_capture_cell = nil
|
|
13
14
|
$__senpi_connection = nil
|
|
14
15
|
$__senpi_frame_io = STDOUT.dup
|
|
@@ -123,8 +124,6 @@ rescue JSON::GeneratorError
|
|
|
123
124
|
value.inspect
|
|
124
125
|
end
|
|
125
126
|
|
|
126
|
-
$__senpi_memory = nil
|
|
127
|
-
|
|
128
127
|
SENPI_SIZER_SAMPLE = 1_000
|
|
129
128
|
SENPI_SIZER_NODE_BUDGET = 5_000
|
|
130
129
|
SENPI_SIZER_MAX_DEPTH = 64
|
|
@@ -239,19 +238,6 @@ rescue StandardError
|
|
|
239
238
|
[]
|
|
240
239
|
end
|
|
241
240
|
|
|
242
|
-
# The host reads the interpreter's footprint and decides on the notice; the runner only names its
|
|
243
|
-
# largest globals. Nothing here spawns a process, so the user's `$?` is left alone.
|
|
244
|
-
def __senpi_memory_report
|
|
245
|
-
return nil if $__senpi_memory.nil?
|
|
246
|
-
named = __senpi_largest_globals(5)
|
|
247
|
-
# The host replaces liveBytes with the interpreter footprint it reads itself; only globals is used.
|
|
248
|
-
report = { "liveBytes" => 0, "measure" => "footprint" }
|
|
249
|
-
report["globals"] = named unless named.empty?
|
|
250
|
-
report
|
|
251
|
-
rescue StandardError
|
|
252
|
-
nil
|
|
253
|
-
end
|
|
254
|
-
|
|
255
241
|
SENPI_NON_DISPLAY_NODES = %i[
|
|
256
242
|
LASGN IASGN GASGN CVASGN DASGN OP_ASGN OP_CDECL CDECL MASGN CASGN
|
|
257
243
|
DEFN DEFS CLASS MODULE SCLASS ALIAS UNDEF
|
|
@@ -305,8 +291,6 @@ def __senpi_run_cell(message)
|
|
|
305
291
|
"durationMs" => ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round,
|
|
306
292
|
}
|
|
307
293
|
frame["valueRepr"] = __senpi_value_repr(value) if !value.nil? && __senpi_should_display_result?(source)
|
|
308
|
-
memory = __senpi_memory_report
|
|
309
|
-
frame["memory"] = memory unless memory.nil?
|
|
310
294
|
__senpi_emit(frame)
|
|
311
295
|
rescue Exception => error
|
|
312
296
|
__senpi_emit({
|
|
@@ -319,6 +303,7 @@ def __senpi_run_cell(message)
|
|
|
319
303
|
ensure
|
|
320
304
|
$__senpi_capture_cell = nil
|
|
321
305
|
$__senpi_current_cell = nil
|
|
306
|
+
$__senpi_memory_cell = cell_id
|
|
322
307
|
end
|
|
323
308
|
end
|
|
324
309
|
|
|
@@ -332,10 +317,15 @@ $__senpi_protocol_stdin.each_line do |line|
|
|
|
332
317
|
when "init"
|
|
333
318
|
__senpi_emit({ "type" => "status", "event" => { "op" => "kernel-startup", "stage" => "host-init" } })
|
|
334
319
|
$__senpi_connection = message["connection"]
|
|
335
|
-
|
|
336
|
-
__senpi_emit({ "type" => "ready" })
|
|
320
|
+
__senpi_emit({ "type" => "ready", "memoryGlobals" => true })
|
|
337
321
|
when "run"
|
|
322
|
+
$__senpi_memory_cell = nil
|
|
338
323
|
__senpi_run_cell(message)
|
|
324
|
+
when "memory-globals"
|
|
325
|
+
if message["cellId"] == $__senpi_memory_cell && $__senpi_current_cell.nil?
|
|
326
|
+
__senpi_emit({ "type" => "memory-globals-result", "cellId" => message["cellId"], "globals" => __senpi_largest_globals(5) })
|
|
327
|
+
$__senpi_memory_cell = nil
|
|
328
|
+
end
|
|
339
329
|
when "close"
|
|
340
330
|
__senpi_emit({ "type" => "closed" })
|
|
341
331
|
exit 0
|
|
@@ -115,7 +115,7 @@ class Execution {
|
|
|
115
115
|
private finished = false;
|
|
116
116
|
// senpi-change begin: output streaming
|
|
117
117
|
private readonly stream: StreamOptions | undefined;
|
|
118
|
-
private readonly credits: Int32Array | undefined;
|
|
118
|
+
private readonly credits: Int32Array<SharedArrayBuffer> | undefined;
|
|
119
119
|
private frameChain: Promise<void> = Promise.resolve();
|
|
120
120
|
private frames = 0;
|
|
121
121
|
private inFlightBytes = 0;
|
|
@@ -188,7 +188,7 @@ class Execution {
|
|
|
188
188
|
// senpi-change begin: output streaming and builtin policy
|
|
189
189
|
...(this.credits === undefined || this.stream === undefined
|
|
190
190
|
? {}
|
|
191
|
-
: { stream: { credits: this.credits.buffer
|
|
191
|
+
: { stream: { credits: this.credits.buffer, frameBytes: this.stream.frameBytes } }),
|
|
192
192
|
storePolicy: options.storePolicy,
|
|
193
193
|
// senpi-change end
|
|
194
194
|
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one read of `bun:ffi` behind a guard: `process.getBuiltinModule` returns the module on Bun and
|
|
3
|
+
* `undefined` on Node, so this module loads on either runtime, and only a result carrying the callable
|
|
4
|
+
* surface the CPU readers bind is treated as FFI - the same boundary `display-js.ts` uses for `Bun`.
|
|
5
|
+
*/
|
|
6
|
+
export type BunFfi = {
|
|
7
|
+
readonly dlopen: <S>(path: string, definitions: Record<string, unknown>) => { readonly symbols: S };
|
|
8
|
+
readonly FFIType: Record<string, number>;
|
|
9
|
+
readonly ptr: (buffer: ArrayBufferView) => number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** True when `value` carries the `bun:ffi` surface the kernel CPU readers use (`dlopen`, `FFIType`, `ptr`). */
|
|
13
|
+
export function isBunFfi(value: unknown): value is BunFfi {
|
|
14
|
+
if (typeof value !== "object" || value === null) return false;
|
|
15
|
+
if (!("dlopen" in value) || typeof value.dlopen !== "function") return false;
|
|
16
|
+
if (!("ptr" in value) || typeof value.ptr !== "function") return false;
|
|
17
|
+
return "FFIType" in value && typeof value.FFIType === "object" && value.FFIType !== null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The runtime's `bun:ffi` module, or `undefined` on Node and on any runtime whose builtin is not one. */
|
|
21
|
+
export function loadBunFfi(): BunFfi | undefined {
|
|
22
|
+
const ffi: unknown = process.getBuiltinModule("bun:ffi");
|
|
23
|
+
return isBunFfi(ffi) ? ffi : undefined;
|
|
24
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { KernelMemoryThresholds } from "../../bridge/memory-protocol.ts";
|
|
1
|
+
import type { KernelMemoryReport, KernelMemoryThresholds } from "../../bridge/memory-protocol.ts";
|
|
2
2
|
import type { KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
3
3
|
import type { EvalLanguage } from "../../tool/types.ts";
|
|
4
4
|
import { KernelMemoryPolicy } from "./kernel-memory.ts";
|
|
@@ -11,7 +11,7 @@ export type FootprintReader = (pid: number) => { readonly bytes: number } | unde
|
|
|
11
11
|
export interface KernelMemoryHostOptions {
|
|
12
12
|
/**
|
|
13
13
|
* Host-measured kernel (rb, jl): the host reads the interpreter's footprint after each result and
|
|
14
|
-
* replaces the runner's live reading with it; the runner
|
|
14
|
+
* replaces the runner's live reading with it; the runner names its largest globals on demand, so the
|
|
15
15
|
* notice and the ceiling both apply, with every reading counting (no collection precedes it).
|
|
16
16
|
*/
|
|
17
17
|
readonly readFootprint?: FootprintReader;
|
|
@@ -36,12 +36,35 @@ export class KernelMemoryHost {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
annotate(result: ResultMessage, pid?: number): ResultMessage {
|
|
39
|
+
return this.annotateReport(result, this.readReport(result, pid));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
readReport(result: ResultMessage, pid?: number): KernelMemoryReport | undefined {
|
|
39
43
|
const runnerReport = result.memory;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
return this.#readFootprint === undefined
|
|
45
|
+
? runnerReport
|
|
46
|
+
: this.#footprintReport(this.#readFootprint, pid, runnerReport);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
needsGlobals(report: KernelMemoryReport): boolean {
|
|
50
|
+
const { noticeBytes, ceilingBytes } = this.#policy.thresholds;
|
|
51
|
+
// Collect at or above the host-read notice threshold or ceiling, regardless of notice hysteresis;
|
|
52
|
+
// never collect below both thresholds. Zero disables a threshold, not the lazy diagnostic gate.
|
|
53
|
+
return (
|
|
54
|
+
(noticeBytes > 0 && report.liveBytes >= noticeBytes) || (ceilingBytes > 0 && report.liveBytes >= ceilingBytes)
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
annotateReport(result: ResultMessage, report: KernelMemoryReport | undefined): ResultMessage {
|
|
59
|
+
if (report === undefined) return result;
|
|
60
|
+
const memory = this.#policy.annotate(report);
|
|
61
|
+
// Safety accounting still observes every cell, but ordinary host-measured results carry no payload.
|
|
62
|
+
// A recycle announcement remains visible even when the replacement process is below threshold.
|
|
63
|
+
if (this.#readFootprint !== undefined && !this.needsGlobals(report) && memory.notice === undefined) {
|
|
64
|
+
const { memory: _memory, ...ordinary } = result;
|
|
65
|
+
return ordinary;
|
|
66
|
+
}
|
|
67
|
+
return { ...result, memory };
|
|
45
68
|
}
|
|
46
69
|
|
|
47
70
|
/**
|
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
* Windows has no process groups; this is the interpreter itself when it is launched directly.
|
|
9
9
|
* - anything else, or Windows under Node: `undefined`, and the caller treats the CPU as unknown.
|
|
10
10
|
*/
|
|
11
|
+
import { type BunFfi, loadBunFfi } from "./bun-ffi.ts";
|
|
11
12
|
import { readProcessGroupCpuTime } from "./process-group-cpu.ts";
|
|
12
13
|
|
|
13
|
-
type BunFfi = typeof import("bun:ffi");
|
|
14
14
|
type CpuReader = (pid: number) => bigint | undefined;
|
|
15
15
|
|
|
16
16
|
const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000;
|
|
@@ -26,7 +26,7 @@ export function readKernelCpuTime(pid: number): bigint | undefined {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
function createWindowsReader(): CpuReader | undefined {
|
|
29
|
-
const ffi =
|
|
29
|
+
const ffi = loadBunFfi();
|
|
30
30
|
if (ffi === undefined) return undefined;
|
|
31
31
|
try {
|
|
32
32
|
return bindGetProcessTimes(ffi);
|
|
@@ -37,7 +37,11 @@ function createWindowsReader(): CpuReader | undefined {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
function bindGetProcessTimes({ dlopen, FFIType, ptr }: BunFfi): CpuReader {
|
|
40
|
-
const library = dlopen
|
|
40
|
+
const library = dlopen<{
|
|
41
|
+
OpenProcess: (access: number, inheritHandle: number, pid: number) => number | null;
|
|
42
|
+
GetProcessTimes: (handle: number, create: number, exit: number, kernel: number, user: number) => number;
|
|
43
|
+
CloseHandle: (handle: number) => number;
|
|
44
|
+
}>("kernel32.dll", {
|
|
41
45
|
OpenProcess: { args: [FFIType.u32, FFIType.i32, FFIType.u32], returns: FFIType.ptr },
|
|
42
46
|
GetProcessTimes: {
|
|
43
47
|
args: [FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr],
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { execFileSync } from "node:child_process";
|
|
17
17
|
import { readdirSync, readFileSync } from "node:fs";
|
|
18
|
+
import { type BunFfi, loadBunFfi } from "./bun-ffi.ts";
|
|
18
19
|
|
|
19
|
-
type BunFfi = typeof import("bun:ffi");
|
|
20
20
|
type GroupCpuReader = (pgid: number) => bigint | undefined;
|
|
21
21
|
|
|
22
22
|
const RUSAGE_INFO_V2 = 2;
|
|
@@ -59,7 +59,7 @@ export function parsePsCpuTime(text: string): bigint | undefined {
|
|
|
59
59
|
function createGroupReader(): GroupCpuReader | undefined {
|
|
60
60
|
if (process.platform === "linux") return linuxGroupReader;
|
|
61
61
|
if (process.platform !== "darwin") return undefined;
|
|
62
|
-
const ffi =
|
|
62
|
+
const ffi = loadBunFfi();
|
|
63
63
|
if (ffi === undefined) return darwinPsGroupReader;
|
|
64
64
|
try {
|
|
65
65
|
return darwinGroupReader(ffi);
|
|
@@ -116,7 +116,10 @@ function linuxGroupReader(pgid: number): bigint | undefined {
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
function darwinGroupReader({ dlopen, FFIType, ptr }: BunFfi): GroupCpuReader {
|
|
119
|
-
const library = dlopen
|
|
119
|
+
const library = dlopen<{
|
|
120
|
+
proc_listpgrppids: (pgid: number, buffer: number, byteLength: number) => number;
|
|
121
|
+
proc_pid_rusage: (pid: number, flavor: number, buffer: number) => number;
|
|
122
|
+
}>("libSystem.B.dylib", {
|
|
120
123
|
proc_listpgrppids: { args: [FFIType.i32, FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
|
|
121
124
|
proc_pid_rusage: { args: [FFIType.i32, FFIType.i32, FFIType.ptr], returns: FFIType.i32 },
|
|
122
125
|
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// allow: SIZE_OK — subprocess protocol, FIFO ownership, retirement, and replacement form one serialized state machine.
|
|
1
2
|
import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
2
3
|
import { decodeBridgeFrame, encodeBridgeFrame, isKernelToHostMessage } from "../../bridge/protocol.ts";
|
|
3
4
|
import type { KernelInterruptHandle, PendingCell } from "../../tool/types.ts";
|
|
@@ -7,6 +8,7 @@ import { applySessionEnvironment } from "../session-env.ts";
|
|
|
7
8
|
import { describeExit } from "./kernel-death.ts";
|
|
8
9
|
import { KernelMemoryHost } from "./kernel-memory-host.ts";
|
|
9
10
|
import type { KernelResult, KernelRunInput, SubprocessKernelOptions, ToolCallMessage } from "./subprocess-contract.ts";
|
|
11
|
+
import { SubprocessMemoryGlobals } from "./subprocess-memory-globals.ts";
|
|
10
12
|
import { type SubprocessLike, SubprocessProcess, type SubprocessSpawn, spawnSubprocess } from "./subprocess-process.ts";
|
|
11
13
|
import { SubprocessRunQueue } from "./subprocess-queue.ts";
|
|
12
14
|
import {
|
|
@@ -39,6 +41,7 @@ export class SubprocessKernel {
|
|
|
39
41
|
private readonly onMessage?: (message: KernelToHostMessage) => void;
|
|
40
42
|
private readonly runs = new SubprocessRunQueue();
|
|
41
43
|
private readonly memory: KernelMemoryHost | null;
|
|
44
|
+
private readonly globals = new SubprocessMemoryGlobals();
|
|
42
45
|
private process: SubprocessProcess | null = null;
|
|
43
46
|
private processReady = false;
|
|
44
47
|
private startup: SubprocessStartupWatchdog | null = null;
|
|
@@ -90,8 +93,10 @@ export class SubprocessKernel {
|
|
|
90
93
|
this.runs.settleAll(new CellInterruptedError(reason));
|
|
91
94
|
return { stateRetained: Promise.resolve(true) };
|
|
92
95
|
}
|
|
96
|
+
if (this.globals.completeWithoutGlobals()) return { stateRetained: Promise.resolve(true) };
|
|
93
97
|
const process = this.process;
|
|
94
98
|
process?.retire();
|
|
99
|
+
this.globals.reset();
|
|
95
100
|
this.runs.clearToolCalls();
|
|
96
101
|
const run = this.runs.active;
|
|
97
102
|
if (!run) return { stateRetained: Promise.resolve(true) };
|
|
@@ -126,6 +131,7 @@ export class SubprocessKernel {
|
|
|
126
131
|
|
|
127
132
|
async reset(): Promise<void> {
|
|
128
133
|
if (this.closed) throw new KernelClosedError();
|
|
134
|
+
this.globals.reset();
|
|
129
135
|
this.settleAll(new KernelResetError());
|
|
130
136
|
this.runs.clearToolCalls();
|
|
131
137
|
await this.restartProcess(this.process);
|
|
@@ -135,6 +141,7 @@ export class SubprocessKernel {
|
|
|
135
141
|
async close(): Promise<void> {
|
|
136
142
|
const wasClosed = this.closed;
|
|
137
143
|
this.closed = true;
|
|
144
|
+
this.globals.reset();
|
|
138
145
|
this.startup?.stop();
|
|
139
146
|
if (!wasClosed) {
|
|
140
147
|
this.settleAll(new KernelClosingError());
|
|
@@ -175,6 +182,7 @@ export class SubprocessKernel {
|
|
|
175
182
|
if (this.runs.active !== run || run.settled) return;
|
|
176
183
|
const process = this.process;
|
|
177
184
|
process?.retire();
|
|
185
|
+
this.globals.reset();
|
|
178
186
|
this.runs.clearToolCalls();
|
|
179
187
|
this.runs.releaseActive(run);
|
|
180
188
|
this.runs.settle(run, timeoutResult(run, timeoutMs));
|
|
@@ -199,6 +207,7 @@ export class SubprocessKernel {
|
|
|
199
207
|
},
|
|
200
208
|
});
|
|
201
209
|
this.process = process;
|
|
210
|
+
this.globals.reset();
|
|
202
211
|
this.processReady = false;
|
|
203
212
|
this.startup?.stop();
|
|
204
213
|
const startup = this.options.startup;
|
|
@@ -237,6 +246,7 @@ export class SubprocessKernel {
|
|
|
237
246
|
if (!this.accepts(process)) return;
|
|
238
247
|
if (!this.processReady) this.startup?.observe(message);
|
|
239
248
|
if (message.type === "ready") {
|
|
249
|
+
this.globals.reset(message.memoryGlobals === true);
|
|
240
250
|
this.startup?.stop();
|
|
241
251
|
this.processReady = true;
|
|
242
252
|
this.runs.handleMessage(message, this.onMessage);
|
|
@@ -248,9 +258,23 @@ export class SubprocessKernel {
|
|
|
248
258
|
this.failClosed(new KernelStartupError(message.error.message));
|
|
249
259
|
return;
|
|
250
260
|
}
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
261
|
+
const run = this.runs.active;
|
|
262
|
+
if (message.type === "memory-globals-result") {
|
|
263
|
+
if (this.memory) this.globals.reply({ process, run }, message, this.memory);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (message.type === "result" && run?.input.cellId === message.cellId && this.memory) {
|
|
267
|
+
this.globals.request({ process, run, result: message }, this.memory, (result) => {
|
|
268
|
+
if (!this.accepts(process) || this.runs.active !== run || run.settled) return;
|
|
269
|
+
this.settleMessage(result);
|
|
270
|
+
});
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
this.settleMessage(message);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private settleMessage(message: KernelToHostMessage): void {
|
|
277
|
+
if (!this.runs.handleMessage(message, this.onMessage)) return;
|
|
254
278
|
this.pumpRuns();
|
|
255
279
|
this.recycleOverCeilingWhenIdle();
|
|
256
280
|
}
|
|
@@ -270,6 +294,7 @@ export class SubprocessKernel {
|
|
|
270
294
|
return;
|
|
271
295
|
}
|
|
272
296
|
this.process = null;
|
|
297
|
+
this.globals.reset();
|
|
273
298
|
this.processReady = false;
|
|
274
299
|
const error = new KernelExitedError(signal ?? code ?? "unknown");
|
|
275
300
|
if (this.options.onDeath) this.die(error, describeExit(code, signal), this.options.onDeath);
|
|
@@ -362,6 +387,7 @@ export class SubprocessKernel {
|
|
|
362
387
|
|
|
363
388
|
private failClosed(error: Error): void {
|
|
364
389
|
this.startup?.stop();
|
|
390
|
+
this.globals.reset();
|
|
365
391
|
const process = this.process;
|
|
366
392
|
this.failure = error;
|
|
367
393
|
this.closed = true;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { KernelMemoryReport } from "../../bridge/memory-protocol.ts";
|
|
2
|
+
import { encodeBridgeFrame, type KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
3
|
+
import type { KernelMemoryHost } from "./kernel-memory-host.ts";
|
|
4
|
+
import type { KernelResult } from "./subprocess-contract.ts";
|
|
5
|
+
import type { SubprocessProcess } from "./subprocess-process.ts";
|
|
6
|
+
import type { PendingRun } from "./subprocess-run.ts";
|
|
7
|
+
|
|
8
|
+
const GLOBALS_TIMEOUT_MS = 5_000;
|
|
9
|
+
type GlobalsProcess = Pick<SubprocessProcess, "send"> & {
|
|
10
|
+
readonly child: { readonly pid?: number };
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
interface PendingGlobals {
|
|
14
|
+
readonly process: GlobalsProcess;
|
|
15
|
+
readonly run: PendingRun;
|
|
16
|
+
readonly result: KernelResult;
|
|
17
|
+
readonly report: KernelMemoryReport;
|
|
18
|
+
readonly memory: KernelMemoryHost;
|
|
19
|
+
readonly finish: (result: KernelResult) => void;
|
|
20
|
+
readonly timer: ReturnType<typeof setTimeout>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Holds FIFO cell ownership until its bounded diagnostic is returned or its deadline expires. */
|
|
24
|
+
export class SubprocessMemoryGlobals {
|
|
25
|
+
#pending: PendingGlobals | null = null;
|
|
26
|
+
#available = false;
|
|
27
|
+
|
|
28
|
+
reset(available = false): void {
|
|
29
|
+
if (this.#pending !== null) clearTimeout(this.#pending.timer);
|
|
30
|
+
this.#pending = null;
|
|
31
|
+
this.#available = available;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
request(
|
|
35
|
+
owner: {
|
|
36
|
+
readonly process: GlobalsProcess;
|
|
37
|
+
readonly run: PendingRun;
|
|
38
|
+
readonly result: KernelResult;
|
|
39
|
+
},
|
|
40
|
+
memory: KernelMemoryHost,
|
|
41
|
+
finish: (result: KernelResult) => void,
|
|
42
|
+
): void {
|
|
43
|
+
if (this.#pending !== null) return;
|
|
44
|
+
const report = memory.readReport(owner.result, owner.process.child.pid);
|
|
45
|
+
if (!this.#available || report === undefined || !memory.needsGlobals(report)) {
|
|
46
|
+
finish(memory.annotateReport(owner.result, report));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const pending: PendingGlobals = {
|
|
50
|
+
...owner,
|
|
51
|
+
report,
|
|
52
|
+
memory,
|
|
53
|
+
finish,
|
|
54
|
+
timer: setTimeout(() => {
|
|
55
|
+
if (this.#pending !== pending) return;
|
|
56
|
+
this.#pending = null;
|
|
57
|
+
pending.finish(memory.annotateReport(pending.result, report));
|
|
58
|
+
}, GLOBALS_TIMEOUT_MS),
|
|
59
|
+
};
|
|
60
|
+
this.#pending = pending;
|
|
61
|
+
owner.process.send(
|
|
62
|
+
encodeBridgeFrame({
|
|
63
|
+
type: "memory-globals",
|
|
64
|
+
cellId: owner.result.cellId,
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Settles an already-finished cell immediately when stop arrives during its optional diagnostic. */
|
|
70
|
+
completeWithoutGlobals(): boolean {
|
|
71
|
+
const pending = this.#pending;
|
|
72
|
+
if (pending === null) return false;
|
|
73
|
+
clearTimeout(pending.timer);
|
|
74
|
+
this.#pending = null;
|
|
75
|
+
pending.finish(pending.memory.annotateReport(pending.result, pending.report));
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
reply(
|
|
80
|
+
owner: {
|
|
81
|
+
readonly process: GlobalsProcess;
|
|
82
|
+
readonly run: PendingRun | null;
|
|
83
|
+
},
|
|
84
|
+
message: Extract<KernelToHostMessage, { type: "memory-globals-result" }>,
|
|
85
|
+
memory: KernelMemoryHost,
|
|
86
|
+
): void {
|
|
87
|
+
const pending = this.#pending;
|
|
88
|
+
if (
|
|
89
|
+
pending === null ||
|
|
90
|
+
pending.process !== owner.process ||
|
|
91
|
+
pending.run !== owner.run ||
|
|
92
|
+
pending.result.cellId !== message.cellId
|
|
93
|
+
)
|
|
94
|
+
return;
|
|
95
|
+
clearTimeout(pending.timer);
|
|
96
|
+
this.#pending = null;
|
|
97
|
+
const report = message.globals.length === 0 ? pending.report : { ...pending.report, globals: message.globals };
|
|
98
|
+
const result = memory.annotateReport(pending.result, report);
|
|
99
|
+
pending.finish(result);
|
|
100
|
+
}
|
|
101
|
+
}
|