@code-yeongyu/senpi-codemode 2026.10.10 → 2026.10.12
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 +214 -0
- package/README.md +113 -11
- package/package.json +7 -6
- package/src/bridge/memory-protocol.ts +8 -1
- package/src/bridge/protocol.ts +9 -4
- package/src/bridge/reserved.ts +2 -0
- package/src/bridges/eval-environment-schemas.ts +52 -0
- package/src/bridges/eval-virtual-schemas.ts +11 -4
- package/src/bridges/reserved-dispatch.ts +13 -1
- package/src/config/executable-settings.json +5 -0
- package/src/config/project-trust.ts +51 -0
- package/src/config/settings-overrides.ts +2 -0
- package/src/environments/editable-check.ts +1 -1
- package/src/environments/packages-install.ts +98 -0
- package/src/environments/py-environment.ts +7 -3
- package/src/environments/py-installer.ts +80 -44
- package/src/environments/python-environments.ts +1 -1
- package/src/extension/bridge-tool-call.ts +5 -1
- package/src/extension/eval-cells-rpc.ts +62 -0
- package/src/extension/eval-notifier.ts +13 -2
- package/src/extension/kernel-registration.ts +7 -2
- package/src/extension/runtime-factory.ts +42 -13
- package/src/extension/runtime-info.ts +6 -2
- package/src/extension/session-cwd.ts +25 -9
- package/src/extension/session-manager-contract.ts +3 -0
- package/src/extension/session-manager.ts +20 -6
- package/src/index.ts +8 -3
- package/src/interpreters/detect.ts +34 -1
- package/src/kernels/AGENTS.md +2 -0
- 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/cell-ownership.js +268 -0
- package/src/kernels/js/cell-run-context.js +89 -0
- package/src/kernels/js/context-manager.ts +23 -24
- package/src/kernels/js/control-frames.ts +24 -0
- package/src/kernels/js/group-signal-notice.js +117 -0
- package/src/kernels/js/inline-worker.ts +2 -0
- package/src/kernels/js/kernel-contract.ts +16 -1
- package/src/kernels/js/kernel-memory-bridge.ts +1 -1
- package/src/kernels/js/kernel-memory.ts +79 -0
- package/src/kernels/js/kernel-tools-registry.js +1 -0
- package/src/kernels/js/local-module-loader.ts +8 -0
- package/src/kernels/js/process-drain-scan.d.ts +8 -0
- package/src/kernels/js/process-drain-scan.js +28 -0
- package/src/kernels/js/process-entry.js +466 -0
- package/src/kernels/js/process-memory-host.ts +83 -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/process-worker.ts +298 -0
- package/src/kernels/js/rejection-reports.js +61 -0
- package/src/kernels/js/run-queue.ts +7 -0
- 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-core.js +51 -8
- package/src/kernels/js/worker-cwd.js +46 -12
- package/src/kernels/js/worker-host.ts +9 -2
- package/src/kernels/js/worker-indirect-eval.js +37 -13
- package/src/kernels/js/worker-require.d.ts +19 -0
- package/src/kernels/js/worker-require.js +52 -0
- package/src/kernels/js/worker-runtime.js +66 -13
- package/src/kernels/js/worker-shadow-guard.js +2 -6
- package/src/kernels/js/worker-shell-capture.js +9 -0
- package/src/kernels/js/worker-slot.ts +8 -0
- package/src/kernels/js/worker-startup.ts +49 -1
- package/src/kernels/js/worker-webview.js +15 -1
- package/src/kernels/py/prelude.py +69 -27
- package/src/kernels/py/process.ts +4 -0
- package/src/kernels/py/transport.ts +4 -0
- package/src/kernels/rb/runner.rb +9 -19
- package/src/kernels/sandbox/sandbox-cell.ts +26 -1
- 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 +24 -2
- package/src/kernels/shared/subprocess-queue.ts +1 -0
- package/src/prompt/eval-prompt.ts +8 -1
- package/src/tool/AGENTS.md +1 -0
- package/src/tool/cell-handler.ts +3 -0
- package/src/tool/cell-reserved-dispatch.ts +1 -0
- package/src/tool/cell-runtime.ts +4 -2
- package/src/tool/detached-cell-contract.ts +3 -0
- package/src/tool/detached-cell-manager.ts +20 -11
- package/src/tool/detached-cell-notification.ts +11 -63
- package/src/tool/detached-cell-snapshot.ts +17 -0
- package/src/tool/detached-eval-result.ts +21 -10
- package/src/tool/detached-notification-queue.ts +1 -2
- package/src/tool/eval-tool.ts +4 -2
- package/src/tool/interrupt-note.ts +4 -5
- package/src/tool/live-headline.ts +60 -0
- package/src/tool/managed-cell.ts +0 -4
- 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 -1069
- package/src/tool/run-eval-cell.ts +24 -14
- package/src/tool/runtime-label.ts +2 -1
- package/src/tool/types.ts +13 -2
package/src/kernels/jl/runner.jl
CHANGED
|
@@ -16,6 +16,7 @@ include("prelude.jl")
|
|
|
16
16
|
const senpi_connection = Dict{String, Any}()
|
|
17
17
|
const senpi_write_lock = ReentrantLock()
|
|
18
18
|
global senpi_current_cell = nothing
|
|
19
|
+
global senpi_memory_cell = nothing
|
|
19
20
|
|
|
20
21
|
function senpi_escape(text::AbstractString)
|
|
21
22
|
out = IOBuffer()
|
|
@@ -290,150 +291,31 @@ function senpi_set_connection(value)
|
|
|
290
291
|
end
|
|
291
292
|
end
|
|
292
293
|
|
|
293
|
-
const
|
|
294
|
+
const SENPI_GLOBALS_INCLUDED = Ref(false)
|
|
294
295
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
const SENPI_SIZER_MIN_REPORTED = 1024 * 1024
|
|
303
|
-
|
|
304
|
-
# Sizes a global with sampling and a node budget per global (leaves count too), so one deep global
|
|
305
|
-
# never hides the ones measured after it and a huge container costs a bounded walk. Only concrete Base
|
|
306
|
-
# containers are iterated; any other AbstractDict or AbstractSet is sized as an opaque struct, so no user
|
|
307
|
-
# length or iterate method runs during a memory report.
|
|
308
|
-
mutable struct SenpiSizer
|
|
309
|
-
seen::IdDict{Any, Nothing}
|
|
310
|
-
nodes::Int
|
|
311
|
-
approximate::Bool
|
|
312
|
-
end
|
|
313
|
-
SenpiSizer() = SenpiSizer(IdDict{Any, Nothing}(), 0, false)
|
|
314
|
-
|
|
315
|
-
senpi_over_budget(sizer::SenpiSizer) = sizer.nodes >= SENPI_SIZER_NODE_BUDGET
|
|
316
|
-
|
|
317
|
-
function senpi_size(sizer::SenpiSizer, value, depth::Int)::Int
|
|
318
|
-
sizer.nodes += 1
|
|
319
|
-
value isa Type && return 0
|
|
320
|
-
value isa Union{Number, Char, Bool, Symbol, Nothing} && return isbits(value) ? sizeof(value) : 0
|
|
321
|
-
value isa String && return SENPI_SIZER_OBJECT + sizeof(value)
|
|
322
|
-
if ismutable(value)
|
|
323
|
-
haskey(sizer.seen, value) && return 0
|
|
324
|
-
sizer.seen[value] = nothing
|
|
325
|
-
end
|
|
326
|
-
if depth >= SENPI_SIZER_MAX_DEPTH || senpi_over_budget(sizer)
|
|
327
|
-
sizer.approximate = true
|
|
328
|
-
return SENPI_SIZER_OBJECT
|
|
329
|
-
end
|
|
330
|
-
if value isa Array
|
|
331
|
-
isbitstype(eltype(value)) && return SENPI_SIZER_OBJECT + sizeof(value)
|
|
332
|
-
return SENPI_SIZER_OBJECT + length(value) * SENPI_SIZER_POINTER +
|
|
333
|
-
senpi_sampled(sizer, length(value), depth) do index
|
|
334
|
-
isassigned(value, index) ? value[index] : nothing
|
|
335
|
-
end
|
|
336
|
-
elseif value isa Union{Dict, IdDict}
|
|
337
|
-
count = length(value)
|
|
338
|
-
total = 0
|
|
339
|
-
taken = 0
|
|
340
|
-
for (key, item) in value
|
|
341
|
-
senpi_over_budget(sizer) && break
|
|
342
|
-
total += senpi_size(sizer, key, depth + 1) + senpi_size(sizer, item, depth + 1)
|
|
343
|
-
taken += 1
|
|
344
|
-
taken >= SENPI_SIZER_SAMPLE && break
|
|
345
|
-
end
|
|
346
|
-
taken < count && (sizer.approximate = true)
|
|
347
|
-
return SENPI_SIZER_OBJECT + count * 2 * SENPI_SIZER_POINTER + (taken == 0 ? 0 : div(total * count, taken))
|
|
348
|
-
elseif value isa Union{Set, Tuple}
|
|
349
|
-
count = length(value)
|
|
350
|
-
total = 0
|
|
351
|
-
taken = 0
|
|
352
|
-
for item in value
|
|
353
|
-
senpi_over_budget(sizer) && break
|
|
354
|
-
total += senpi_size(sizer, item, depth + 1)
|
|
355
|
-
taken += 1
|
|
356
|
-
taken >= SENPI_SIZER_SAMPLE && break
|
|
357
|
-
end
|
|
358
|
-
taken < count && (sizer.approximate = true)
|
|
359
|
-
return SENPI_SIZER_OBJECT + count * SENPI_SIZER_POINTER + (taken == 0 ? 0 : div(total * count, taken))
|
|
360
|
-
end
|
|
361
|
-
isbits(value) && return sizeof(value)
|
|
362
|
-
fields = fieldcount(typeof(value))
|
|
363
|
-
return SENPI_SIZER_OBJECT + fields * SENPI_SIZER_POINTER +
|
|
364
|
-
senpi_sampled(sizer, fields, depth) do index
|
|
365
|
-
isdefined(value, index) ? getfield(value, index) : nothing
|
|
366
|
-
end
|
|
367
|
-
end
|
|
368
|
-
|
|
369
|
-
# Up to SENPI_SIZER_SAMPLE evenly spaced elements; once the budget runs out part-way, the elements measured
|
|
370
|
-
# so far stand in for the rest.
|
|
371
|
-
function senpi_sampled(at, sizer::SenpiSizer, count::Int, depth::Int)::Int
|
|
372
|
-
count == 0 && return 0
|
|
373
|
-
picks = min(count, SENPI_SIZER_SAMPLE)
|
|
374
|
-
picks < count && (sizer.approximate = true)
|
|
375
|
-
step = count / picks
|
|
376
|
-
total = 0
|
|
377
|
-
measured = 0
|
|
378
|
-
for sample in 1:picks
|
|
379
|
-
if senpi_over_budget(sizer)
|
|
380
|
-
sizer.approximate = true
|
|
381
|
-
break
|
|
296
|
+
function senpi_memory_globals(limit::Int)
|
|
297
|
+
if !SENPI_GLOBALS_INCLUDED[]
|
|
298
|
+
try
|
|
299
|
+
include(joinpath(@__DIR__, "globals.jl"))
|
|
300
|
+
SENPI_GLOBALS_INCLUDED[] = true
|
|
301
|
+
catch
|
|
302
|
+
return Dict{String, Any}[]
|
|
382
303
|
end
|
|
383
|
-
total += senpi_size(sizer, at(1 + floor(Int, (sample - 1) * step)), depth + 1)
|
|
384
|
-
measured += 1
|
|
385
304
|
end
|
|
386
|
-
|
|
305
|
+
Base.invokelatest(() -> senpi_largest_globals(limit))
|
|
387
306
|
end
|
|
388
307
|
|
|
389
|
-
function
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
name in (:Main, :Base, :Core, :Ans, :ans) && continue
|
|
397
|
-
lowered = lowercase(string(name))
|
|
398
|
-
startswith(lowered, "senpi_") && continue
|
|
399
|
-
startswith(string(name), "Senpi") && continue
|
|
400
|
-
startswith(string(name), "#") && continue
|
|
401
|
-
isdefined(Main, name) || continue
|
|
402
|
-
value = getfield(Main, name)
|
|
403
|
-
value isa Module && continue
|
|
404
|
-
value isa IO && continue
|
|
405
|
-
value isa Type && continue
|
|
406
|
-
value isa Function && continue
|
|
407
|
-
sizer.approximate = false
|
|
408
|
-
sizer.nodes = 0
|
|
409
|
-
bytes = try
|
|
410
|
-
senpi_size(sizer, value, 0) + SENPI_SIZER_POINTER
|
|
411
|
-
catch
|
|
412
|
-
0
|
|
413
|
-
end
|
|
414
|
-
bytes >= SENPI_SIZER_MIN_REPORTED && push!(candidates, (string(name), bytes, sizer.approximate))
|
|
415
|
-
end
|
|
416
|
-
end
|
|
417
|
-
sort!(candidates, by = entry -> entry[2], rev = true)
|
|
418
|
-
[approximate ? Dict{String, Any}("name" => name, "bytes" => bytes, "approximate" => true) : Dict{String, Any}("name" => name, "bytes" => bytes) for (name, bytes, approximate) in candidates[1:min(limit, end)]]
|
|
419
|
-
catch
|
|
420
|
-
Dict{String, Any}[]
|
|
421
|
-
end
|
|
422
|
-
end
|
|
423
|
-
|
|
424
|
-
function senpi_memory_report()
|
|
425
|
-
try
|
|
426
|
-
senpi_memory[] === nothing && return nothing
|
|
427
|
-
# The host reads the interpreter's current footprint and decides on the notice; the runner only
|
|
428
|
-
# names its largest globals, with a bounded walk, so a cell never pays for an unbounded scan.
|
|
429
|
-
named = senpi_largest_globals(5)
|
|
430
|
-
# The host replaces liveBytes with the interpreter footprint it reads itself; only globals is used.
|
|
431
|
-
report = Dict{String, Any}("liveBytes" => 0, "measure" => "footprint")
|
|
432
|
-
isempty(named) || (report["globals"] = named)
|
|
433
|
-
report
|
|
434
|
-
catch
|
|
435
|
-
nothing
|
|
308
|
+
function senpi_needs_handles(expression)
|
|
309
|
+
if expression isa Symbol
|
|
310
|
+
expression in (:handle, :completion, :eval, :include, :getfield, :getglobal, :deserialize) && return true
|
|
311
|
+
name = string(expression)
|
|
312
|
+
return startswith(name, "SenpiHandle") || startswith(name, "senpi_handle") ||
|
|
313
|
+
startswith(name, "senpi_wait") ||
|
|
314
|
+
(startswith(name, "SENPI_") && (occursin("HANDLE", name) || occursin("WAIT", name)))
|
|
436
315
|
end
|
|
316
|
+
expression isa QuoteNode && return senpi_needs_handles(expression.value)
|
|
317
|
+
expression isa Expr && return any(senpi_needs_handles, expression.args)
|
|
318
|
+
false
|
|
437
319
|
end
|
|
438
320
|
|
|
439
321
|
function senpi_run_cell(message)
|
|
@@ -446,19 +328,23 @@ function senpi_run_cell(message)
|
|
|
446
328
|
if parsed isa Expr && parsed.head === :error
|
|
447
329
|
error(string(parsed.args[1]))
|
|
448
330
|
end
|
|
331
|
+
# Install bindings before user functions/type annotations capture their world.
|
|
332
|
+
!SENPI_HANDLES_INCLUDED[] && senpi_needs_handles(parsed) && senpi_ensure_handles()
|
|
449
333
|
value = Core.eval(Main, parsed)
|
|
450
334
|
flush(stdout)
|
|
451
335
|
flush(stderr)
|
|
452
336
|
yield()
|
|
453
337
|
frame = Dict{String, Any}("type" => "result", "cellId" => cell_id, "ok" => true, "durationMs" => round(Int, (time() - started) * 1000))
|
|
454
|
-
value !== nothing && senpi_should_display_result(parsed)
|
|
455
|
-
|
|
456
|
-
|
|
338
|
+
if value !== nothing && senpi_should_display_result(parsed)
|
|
339
|
+
# Core.eval does not advance this caller's world after a first lazy include.
|
|
340
|
+
frame["valueRepr"] = SENPI_HANDLES_INCLUDED[] ? Base.invokelatest(senpi_json, value) : senpi_json(value)
|
|
341
|
+
end
|
|
457
342
|
senpi_emit(frame)
|
|
458
343
|
catch error
|
|
459
344
|
senpi_emit(Dict("type" => "result", "cellId" => cell_id, "ok" => false, "error" => senpi_error(error), "durationMs" => round(Int, (time() - started) * 1000)))
|
|
460
345
|
finally
|
|
461
346
|
global senpi_current_cell = nothing
|
|
347
|
+
global senpi_memory_cell = cell_id
|
|
462
348
|
end
|
|
463
349
|
end
|
|
464
350
|
|
|
@@ -472,10 +358,16 @@ while !eof(SENPI_ORIGINAL_STDIN)
|
|
|
472
358
|
if kind == "init"
|
|
473
359
|
senpi_emit(Dict("type" => "status", "event" => Dict("op" => "kernel-startup", "stage" => "host-init")))
|
|
474
360
|
senpi_set_connection(get(message, "connection", nothing))
|
|
475
|
-
|
|
476
|
-
senpi_emit(Dict("type" => "ready"))
|
|
361
|
+
senpi_emit(Dict("type" => "ready", "memoryGlobals" => true))
|
|
477
362
|
elseif kind == "run"
|
|
363
|
+
global senpi_memory_cell = nothing
|
|
478
364
|
senpi_run_cell(message)
|
|
365
|
+
elseif kind == "memory-globals"
|
|
366
|
+
cell_id = get(message, "cellId", nothing)
|
|
367
|
+
if cell_id == senpi_memory_cell && senpi_current_cell === nothing
|
|
368
|
+
senpi_emit(Dict("type" => "memory-globals-result", "cellId" => cell_id, "globals" => senpi_memory_globals(5)))
|
|
369
|
+
global senpi_memory_cell = nothing
|
|
370
|
+
end
|
|
479
371
|
elseif kind == "close"
|
|
480
372
|
senpi_emit(Dict("type" => "closed"))
|
|
481
373
|
break
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { currentCell, onReleaseOf, settleWithCell } from "./cell-run-context.js";
|
|
2
|
+
|
|
3
|
+
// What a cell owns, and how each kind ends when the cell is stopped. Every patch is a no-op outside a cell, refuses
|
|
4
|
+
// (or never starts) new work for a released cell, and registers what a live cell starts so release can end it.
|
|
5
|
+
const RESOURCE_CLOSERS = ["terminate", "destroy", "stop", "close", "end"];
|
|
6
|
+
|
|
7
|
+
// Some host bindings are read-only (Bun.fetch); those keep their own behaviour and are not owned by cells.
|
|
8
|
+
function replace(owner, name, replacement, patched) {
|
|
9
|
+
const original = owner[name];
|
|
10
|
+
try {
|
|
11
|
+
owner[name] = replacement;
|
|
12
|
+
} catch {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
if (owner[name] !== replacement) return;
|
|
16
|
+
patched.push(() => {
|
|
17
|
+
owner[name] = original;
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function builtin(name) {
|
|
22
|
+
return process.getBuiltinModule?.(name);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function closeResource(resource) {
|
|
26
|
+
for (const method of RESOURCE_CLOSERS) {
|
|
27
|
+
if (typeof resource?.[method] !== "function") continue;
|
|
28
|
+
if (method === "stop") resource.stop(true);
|
|
29
|
+
else resource[method]();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function ownResultsOf(owner, names, patched) {
|
|
35
|
+
for (const name of names) {
|
|
36
|
+
const original = owner?.[name];
|
|
37
|
+
if (typeof original !== "function") continue;
|
|
38
|
+
const own = function (...args) {
|
|
39
|
+
const cell = currentCell();
|
|
40
|
+
if (cell === undefined) return original.apply(this, args);
|
|
41
|
+
if (cell.released) throw cell.interruption;
|
|
42
|
+
const result = original.apply(this, args);
|
|
43
|
+
const register = (resource) => {
|
|
44
|
+
onReleaseOf(cell, () => closeResource(resource));
|
|
45
|
+
return resource;
|
|
46
|
+
};
|
|
47
|
+
return result instanceof Promise ? result.then(register) : register(result);
|
|
48
|
+
};
|
|
49
|
+
Object.assign(own, original);
|
|
50
|
+
replace(owner, name, own, patched);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// `globals` is the global scope already patched: a module binding that is the same owned function is left alone.
|
|
55
|
+
function patchTimers(scope, patched, globals = undefined) {
|
|
56
|
+
for (const [name, clearName] of [
|
|
57
|
+
["setTimeout", "clearTimeout"],
|
|
58
|
+
["setInterval", "clearInterval"],
|
|
59
|
+
["setImmediate", "clearImmediate"],
|
|
60
|
+
]) {
|
|
61
|
+
const original = scope?.[name];
|
|
62
|
+
const clear = scope?.[clearName];
|
|
63
|
+
if (typeof original !== "function" || typeof clear !== "function") continue;
|
|
64
|
+
if (globals !== undefined && original === globals[name]) continue;
|
|
65
|
+
const owned = function (callback, ...rest) {
|
|
66
|
+
const cell = currentCell();
|
|
67
|
+
if (cell === undefined || typeof callback !== "function") return original.call(this, callback, ...rest);
|
|
68
|
+
if (cell.released) {
|
|
69
|
+
const never = original.call(this, () => {}, ...rest);
|
|
70
|
+
clear.call(scope, never);
|
|
71
|
+
return never;
|
|
72
|
+
}
|
|
73
|
+
let forget = () => {};
|
|
74
|
+
const handle = original.call(
|
|
75
|
+
this,
|
|
76
|
+
function (...args) {
|
|
77
|
+
if (name !== "setInterval") forget();
|
|
78
|
+
return callback.apply(this, args);
|
|
79
|
+
},
|
|
80
|
+
...rest,
|
|
81
|
+
);
|
|
82
|
+
forget = onReleaseOf(cell, () => clear.call(scope, handle));
|
|
83
|
+
return handle;
|
|
84
|
+
};
|
|
85
|
+
Object.assign(owned, original);
|
|
86
|
+
replace(scope, name, owned, patched);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function patchPromiseApi(owner, name, patched) {
|
|
91
|
+
const original = owner?.[name];
|
|
92
|
+
if (typeof original !== "function") return;
|
|
93
|
+
const bound = function (...args) {
|
|
94
|
+
const cell = currentCell();
|
|
95
|
+
if (cell === undefined) return original.apply(this, args);
|
|
96
|
+
if (cell.released) return Promise.reject(cell.interruption);
|
|
97
|
+
return settleWithCell(cell, original.apply(this, args));
|
|
98
|
+
};
|
|
99
|
+
Object.assign(bound, original);
|
|
100
|
+
replace(owner, name, bound, patched);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function patchTimerPromises(patched) {
|
|
104
|
+
const timers = builtin("node:timers/promises");
|
|
105
|
+
if (timers === undefined) return;
|
|
106
|
+
patchPromiseApi(timers, "setTimeout", patched);
|
|
107
|
+
patchPromiseApi(timers, "setImmediate", patched);
|
|
108
|
+
const original = timers.setInterval;
|
|
109
|
+
if (typeof original !== "function") return;
|
|
110
|
+
const owned = function (...args) {
|
|
111
|
+
const cell = currentCell();
|
|
112
|
+
if (cell?.released) throw cell.interruption;
|
|
113
|
+
const iterator = original.apply(this, args);
|
|
114
|
+
if (cell === undefined) return iterator;
|
|
115
|
+
onReleaseOf(cell, () => void iterator.return?.());
|
|
116
|
+
return iterator;
|
|
117
|
+
};
|
|
118
|
+
replace(timers, "setInterval", owned, patched);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function patchFetch(scope, patched, name = "fetch") {
|
|
122
|
+
const original = scope[name];
|
|
123
|
+
if (typeof original !== "function") return;
|
|
124
|
+
const owned = function (input, init) {
|
|
125
|
+
const cell = currentCell();
|
|
126
|
+
if (cell === undefined) return original.call(this, input, init);
|
|
127
|
+
if (cell.released) return Promise.reject(cell.interruption);
|
|
128
|
+
const controller = new AbortController();
|
|
129
|
+
const own = init?.signal ?? (input instanceof Request ? input.signal : undefined);
|
|
130
|
+
const signal = own === undefined ? controller.signal : AbortSignal.any([own, controller.signal]);
|
|
131
|
+
const forget = onReleaseOf(cell, () => controller.abort(cell.interruption));
|
|
132
|
+
return original.call(this, input, { ...init, signal }).finally(forget);
|
|
133
|
+
};
|
|
134
|
+
Object.assign(owned, original);
|
|
135
|
+
replace(scope, name, owned, patched);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// A released cell's code may still be resumed by a short I/O completion; refusing new I/O at that point makes such a
|
|
139
|
+
// loop throw the interruption on its next operation instead of running on in the kept worker.
|
|
140
|
+
function refuseWhenReleased(owner, names, patched) {
|
|
141
|
+
for (const name of names) {
|
|
142
|
+
const original = owner?.[name];
|
|
143
|
+
if (typeof original !== "function") continue;
|
|
144
|
+
const guarded = function (...args) {
|
|
145
|
+
const cell = currentCell();
|
|
146
|
+
if (cell?.released) throw cell.interruption;
|
|
147
|
+
const result = original.apply(this, args);
|
|
148
|
+
return cell === undefined ? result : settleWithCell(cell, result);
|
|
149
|
+
};
|
|
150
|
+
Object.assign(guarded, original);
|
|
151
|
+
replace(owner, name, guarded, patched);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// The callback form: a released cell's operation is never started, so its callback never runs, the same way a timer a
|
|
156
|
+
// released cell sets never fires.
|
|
157
|
+
function neverStartWhenReleased(owner, names, patched) {
|
|
158
|
+
for (const name of names) {
|
|
159
|
+
const original = owner?.[name];
|
|
160
|
+
if (typeof original !== "function") continue;
|
|
161
|
+
const guarded = function (...args) {
|
|
162
|
+
if (currentCell()?.released) return undefined;
|
|
163
|
+
return original.apply(this, args);
|
|
164
|
+
};
|
|
165
|
+
Object.assign(guarded, original);
|
|
166
|
+
replace(owner, name, guarded, patched);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const FS_OPERATIONS = ["readFile", "writeFile", "appendFile", "readdir", "stat", "lstat", "open", "access", "mkdir", "rm", "unlink", "rename", "copyFile"];
|
|
171
|
+
|
|
172
|
+
// For ES classes only (Worker, WebSocket, MessageChannel): `instanceof` keeps accepting instances the runtime creates
|
|
173
|
+
// internally from the original class. Node's function-style constructors are owned on activation instead.
|
|
174
|
+
function patchClass(scope, name, close, patched) {
|
|
175
|
+
const Original = scope?.[name];
|
|
176
|
+
if (typeof Original !== "function") return;
|
|
177
|
+
const Owned = class extends Original {
|
|
178
|
+
constructor(...args) {
|
|
179
|
+
const cell = currentCell();
|
|
180
|
+
if (cell?.released) throw cell.interruption;
|
|
181
|
+
super(...args);
|
|
182
|
+
if (cell !== undefined) onReleaseOf(cell, () => close(this));
|
|
183
|
+
}
|
|
184
|
+
static [Symbol.hasInstance](value) {
|
|
185
|
+
return value instanceof Original;
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
Object.defineProperty(Owned, "name", { value: Original.name });
|
|
189
|
+
replace(scope, name, Owned, patched);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const NODE_RESOURCE_FACTORIES = {
|
|
193
|
+
"node:net": ["createConnection", "connect", "createServer"],
|
|
194
|
+
"node:tls": ["connect", "createServer"],
|
|
195
|
+
"node:http": ["request", "get", "createServer"],
|
|
196
|
+
"node:https": ["request", "get", "createServer"],
|
|
197
|
+
"node:dgram": ["createSocket"],
|
|
198
|
+
"node:fs": ["createReadStream", "createWriteStream", "watch"],
|
|
199
|
+
"node:readline": ["createInterface"],
|
|
200
|
+
"node:readline/promises": ["createInterface"],
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// Node's socket and server types are function-style constructors that Node's own subclasses call with `.call(this)`
|
|
204
|
+
// (http.Server calls net.Server), so they cannot be swapped for a class. A socket or server only does anything once it
|
|
205
|
+
// connects, listens or binds, so that call is where a cell takes ownership of it; http, https and tls inherit these.
|
|
206
|
+
const NODE_ACTIVATIONS = {
|
|
207
|
+
"node:net": { Socket: ["connect"], Server: ["listen"] },
|
|
208
|
+
"node:dgram": { Socket: ["bind"] },
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
function ownOnActivation(proto, names, patched) {
|
|
212
|
+
for (const name of names) {
|
|
213
|
+
const original = proto?.[name];
|
|
214
|
+
if (typeof original !== "function") continue;
|
|
215
|
+
const owned = new WeakSet();
|
|
216
|
+
const activate = function (...args) {
|
|
217
|
+
const cell = currentCell();
|
|
218
|
+
if (cell === undefined) return original.apply(this, args);
|
|
219
|
+
if (cell.released) throw cell.interruption;
|
|
220
|
+
if (!owned.has(this)) {
|
|
221
|
+
owned.add(this);
|
|
222
|
+
onReleaseOf(cell, () => closeResource(this));
|
|
223
|
+
}
|
|
224
|
+
return original.apply(this, args);
|
|
225
|
+
};
|
|
226
|
+
replace(proto, name, activate, patched);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function installCellOwnership(scope = globalThis) {
|
|
231
|
+
const patched = [];
|
|
232
|
+
patchTimers(scope, patched);
|
|
233
|
+
patchTimers(builtin("node:timers"), patched, scope);
|
|
234
|
+
patchTimerPromises(patched);
|
|
235
|
+
patchFetch(scope, patched);
|
|
236
|
+
patchClass(scope, "WebSocket", (socket) => socket.close(), patched);
|
|
237
|
+
patchClass(scope, "Worker", (worker) => void worker.terminate(), patched);
|
|
238
|
+
refuseWhenReleased(builtin("node:fs/promises"), FS_OPERATIONS, patched);
|
|
239
|
+
neverStartWhenReleased(builtin("node:fs"), FS_OPERATIONS, patched);
|
|
240
|
+
patchClass(scope, "MessageChannel", (channel) => {
|
|
241
|
+
channel.port1.close();
|
|
242
|
+
channel.port2.close();
|
|
243
|
+
}, patched);
|
|
244
|
+
patchClass(scope, "BroadcastChannel", (channel) => channel.close(), patched);
|
|
245
|
+
const bun = scope.Bun;
|
|
246
|
+
if (bun !== undefined) {
|
|
247
|
+
patchPromiseApi(bun, "sleep", patched);
|
|
248
|
+
if (bun.fetch !== scope.fetch) patchFetch(bun, patched, "fetch");
|
|
249
|
+
refuseWhenReleased(bun, ["file", "write"], patched);
|
|
250
|
+
ownResultsOf(bun, ["connect", "listen", "serve", "udpSocket"], patched);
|
|
251
|
+
}
|
|
252
|
+
for (const [module, factories] of Object.entries(NODE_RESOURCE_FACTORIES)) ownResultsOf(builtin(module), factories, patched);
|
|
253
|
+
patchClass(builtin("node:worker_threads"), "Worker", (worker) => void worker.terminate(), patched);
|
|
254
|
+
for (const [module, types] of Object.entries(NODE_ACTIVATIONS)) {
|
|
255
|
+
for (const [name, methods] of Object.entries(types)) ownOnActivation(builtin(module)?.[name]?.prototype, methods, patched);
|
|
256
|
+
}
|
|
257
|
+
// Node hands `import("node:…")` its own ESM namespace; without this sync a cell importing the module gets the
|
|
258
|
+
// unowned originals.
|
|
259
|
+
syncBuiltinExports();
|
|
260
|
+
return () => {
|
|
261
|
+
for (const undo of patched.reverse()) undo();
|
|
262
|
+
syncBuiltinExports();
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function syncBuiltinExports() {
|
|
267
|
+
builtin("node:module")?.syncBuiltinESMExports?.();
|
|
268
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
|
|
3
|
+
// Every continuation a cell creates (awaits, timers, I/O callbacks) runs inside its cell record. Stopping a cell
|
|
4
|
+
// releases it: everything it owns is cleared, rejected or closed (`cell-ownership.js` decides what a cell owns), and
|
|
5
|
+
// anything its code tries to start afterwards is refused, while the kernel and its variables stay.
|
|
6
|
+
const cellRuns = new AsyncLocalStorage();
|
|
7
|
+
const releasedInterruptions = new WeakMap();
|
|
8
|
+
const MAX_CAUSE_DEPTH = 8;
|
|
9
|
+
|
|
10
|
+
export function runInCell(cell, run) {
|
|
11
|
+
return cellRuns.run(cell, run);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function currentCell() {
|
|
15
|
+
return cellRuns.getStore();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function releasedCellError() {
|
|
19
|
+
const cell = cellRuns.getStore();
|
|
20
|
+
return cell?.released === true ? cell.interruption : undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function assertCellLive() {
|
|
24
|
+
const error = releasedCellError();
|
|
25
|
+
if (error !== undefined) throw error;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function onCellRelease(close) {
|
|
29
|
+
const cell = cellRuns.getStore();
|
|
30
|
+
return cell === undefined ? () => {} : onReleaseOf(cell, close);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function onReleaseOf(cell, close) {
|
|
34
|
+
cell.onRelease ??= new Set();
|
|
35
|
+
cell.onRelease.add(close);
|
|
36
|
+
return () => cell.onRelease.delete(close);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Releases the cell: every closer runs even when an earlier one throws, and the failures are returned so the caller
|
|
41
|
+
* can report them; a resource that would not close is a fact the user should see, not something to swallow.
|
|
42
|
+
*/
|
|
43
|
+
export function releaseCell(cell) {
|
|
44
|
+
cell.released = true;
|
|
45
|
+
releasedInterruptions.set(cell.interruption, cell);
|
|
46
|
+
const failures = [];
|
|
47
|
+
for (const close of cell.onRelease ?? []) {
|
|
48
|
+
try {
|
|
49
|
+
close();
|
|
50
|
+
} catch (error) {
|
|
51
|
+
failures.push(error);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
cell.onRelease?.clear();
|
|
55
|
+
return failures;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The cell an unhandled rejection came from: a released cell whose interruption is the reason or sits in its `cause`
|
|
60
|
+
* chain, or the cell whose async context the rejection runs in (Node exposes it to the handler; Bun does not).
|
|
61
|
+
*/
|
|
62
|
+
export function cellOfRejection(reason) {
|
|
63
|
+
let current = reason;
|
|
64
|
+
for (let depth = 0; depth < MAX_CAUSE_DEPTH && typeof current === "object" && current !== null; depth++) {
|
|
65
|
+
const cell = releasedInterruptions.get(current);
|
|
66
|
+
if (cell !== undefined) return cell;
|
|
67
|
+
current = current.cause;
|
|
68
|
+
}
|
|
69
|
+
return cellRuns.getStore();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// An operation already in flight when its cell is released rejects with the interruption at once, so the stopped
|
|
73
|
+
// cell's continuation never runs again; a non-promise result is returned as it is.
|
|
74
|
+
export function settleWithCell(cell, result) {
|
|
75
|
+
if (result === null || typeof result !== "object" || typeof result.then !== "function") return result;
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
const forget = onReleaseOf(cell, () => reject(cell.interruption));
|
|
78
|
+
result.then(
|
|
79
|
+
(value) => {
|
|
80
|
+
forget();
|
|
81
|
+
resolve(value);
|
|
82
|
+
},
|
|
83
|
+
(error) => {
|
|
84
|
+
forget();
|
|
85
|
+
reject(error);
|
|
86
|
+
},
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
}
|