Tool-calling at pool speed: session-affine warm runtimes
Design only, no code changed. Grounded in a full read of src/runtime/
(pool.rs, runner.rs, ops.rs, snapshot.rs, config.rs,
python/runtime.rs) on main, plus a fresh measurement run whose numbers
are tabulated below. Line numbers refer to the files as read on main at
2026-09-21.
Summary
The brief that motivated this document asked for "tool-calling at near-pool
speed", on the premise that IsolatePool is the fast path and the ops path
has to catch up to it. That premise is wrong, and measurement inverts it.
A warm Runtime executes a complete host tool call — JS calls a bound
function, the op crosses into the host, a value comes back — in 13.4 µs.
An IsolatePool checkout, which does one eval and cannot host a tool
call at all, costs 167–185 µs (BENCHMARKS.md). Reusing a warm
Runtime is not "near" pool speed; it is ~12x faster than pool speed,
and it has hosted ops the whole time.
The pool's advertised "16–18x" is real but is a comparison against
constructing a new runtime per call (~3 ms), not against retaining one.
Nobody ever benchmarked "keep the Runtime and call it again", which is the
cheapest thing in the entire system. Once you do, the architecture question
dissolves:
- Pooling was never the fast path for the ops case. It is the fast path
for the stateless, mutually-untrusting, one-eval-each case, where a
fresh
Contextper call is the product requirement. Tool-calling executions are the opposite: they belong to one session, one user, one trust boundary, and they want continuity. deno_core's privateJsRealmis therefore not blocking anything we want.JsRealmmultiplexing would buy fresh-Context-per-call with ops — i.e. ~167 µs and no continuity. That is 12x slower than simply keeping the session's runtime. The API wall is real; it is also guarding a door we have no reason to walk through. See Thedeno_corefork, priced.- Snapshots are a genuine second lever, and better than expected. A post-bootstrap snapshot cuts cold start from 3.15 ms to 1.25 ms, and ops do survive snapshotting (measured, see Snapshots). That halves the only cost session affinity actually has: the first call in a session.
So the recommendation is the cheap one. Retain one Runtime per session,
keyed by session id, with LRU + idle-TTL eviction. Do not reset it. Do not
pool it. Do not fork deno_core. The expensive-looking parts of the
problem were artifacts of measuring the wrong baseline.
Update, peno 0.2.0. As first written, this document had one load-bearing error: it assumed a retained
Runtime's thread was parked when idle. It was not — the dispatcher busy-spun, costing ~13% of a core per idle runtime and saturating an 8-core machine by K=64, which capped the very pattern recommended here at roughly the core count. 0.2.0 makes the dispatcher park, measured at 0.0% idle CPU and flat ~9.8 µs per-call latency through K=64. The recommendation below is unchanged; see the corrected thread-cost discussion andBENCHMARKS.mdfor the numbers.Update, peno 0.2.1. Every tool-call figure in this document was measured without
timeout=, and through 0.2.0 that was the only configuration in which they held: arming any deadline added a fixed ~13 ms, because the timeout watchdog polled for its own cancellation behind a 10 ms sleep while the runtime thread joined it. Since a production caller must arm a timeout — it is the only kill switch for runaway guest code — the 13.4 µs below was only ever reachable unsafely. 0.2.1 signals the cancel instead of polling for it; the armed path is now ~1.2x the unarmed one, so these numbers now describe the configuration you should actually deploy. The recommendation below is unchanged; see Arming a timeout.
Measurements
Apple M2, 8 cores, 16 GB, macOS 25.5.0 arm64; Python 3.12; release build
(maturin build --release) of the tool-bridge branch. Medians over 15–2000
iterations depending on the operation. These are one real run, not
simulated, and are environment-dependent in the same way BENCHMARKS.md
warns about. Rows marked (BENCHMARKS.md) are pre-existing repo numbers
quoted for comparison, measured on the same machine class.
| Operation | Cost | Hosts ops? |
|---|---|---|
Warm Runtime, full tool call (bind_function → JS → host → value) |
13.4 µs | yes |
Warm Runtime, tool call, snapshot-started |
11.0 µs | yes |
Warm Runtime, plain eval (1+41) |
3.77 µs | yes |
IsolatePool checkout + eval + release (BENCHMARKS.md) |
167–185 µs | no |
Cold Runtime() + one eval |
3.15 ms | yes |
Cold Runtime() + bind_function + one tool call |
3.14 ms | yes |
Cold Runtime(snapshot=…) + one eval |
1.25 ms | yes |
Cold Runtime(bootstrap=<same src>) + one eval |
3.04 ms | yes |
| Cold, heavy 200 KB bootstrap: snapshot | 2.27 ms | yes |
| Cold, heavy 200 KB bootstrap: from source | 7.06 ms | yes |
SnapshotBuilder(...).build(), one-off |
15.3 ms (tiny) / 38.6 ms (heavy) | n/a |
| Snapshot size | 721 KB (tiny) / 1.54 MB (heavy) | n/a |
Retained-runtime memory, measured as process RSS high-water with K runtimes alive, each with a bound tool and one call executed:
| K retained | plain Runtime |
snapshot-started |
|---|---|---|
| 1 | +0.0 MB | +0.4 MB |
| 4 | +7.3 MB (1.82 MB/session) | +7.1 MB (1.77 MB/session) |
| 16 | +42.8 MB (2.68 MB/session) | +35.1 MB (2.19 MB/session) |
| 64 | +186.0 MB (2.91 MB/session) | +149.2 MB (2.33 MB/session) |
Caveat on method, stated because it changes how the curve should be read:
this is ru_maxrss, a high-water mark, so it is monotonic and cannot
show reclamation. It is the right shape for "what does holding K runtimes
cost" and the wrong tool for "does eviction give memory back" — that second
question is untested and is called out as a deferral below. The K=1 row
reading +0.0 MB is an artifact of the baseline already containing a
created-and-closed runtime's high-water, not evidence that the first runtime
is free.
Per-session cost, in one line: a session with M tool calls costs
3.14 ms + M × 13.4 µs (or 1.25 ms + M × 11.0 µs snapshot-started),
against M × 3.14 ms for construct-per-call. At M=100 that is 4.5 ms versus
314 ms — 70x — and the amortisation is already better than the pool's at
M=1.
What the measurements changed
Two things, both of which moved the design:
- The 13.4 µs warm tool call. Before measuring, the plan was going to be
"get as close to 167 µs as possible". The real number is an order of
magnitude past the target, which is what retires pooling,
JsRealm, and the fork from consideration rather than merely deprioritising them. - Snapshots recovering 60% of cold start, with ops intact. Reasoning
from
BENCHMARKS.mdalone predicted snapshots would be nearly useless:isolate_creation_and_closeis 2.52 ms of a 2.82 ms cold start, so "everything that isn't isolate construction" looked like ≤0.3 ms of headroom. That reasoning was wrong, and measurement corrected it — 3.15 ms → 1.25 ms. The 2.52 ms figure evidently includesJsRuntimebootstrap work that a snapshot does elide, not just rawv8::Isolateallocation. Recording the wrong prediction here deliberately: the cost-breakdown inference was plausible and false, and the next person reasoning from those two numbers will make the same mistake.
Relationship to the in-flight measurement work
A separate measurement effort was quantifying the same four candidate paths (warm reuse, global-state reset, snapshot startup, per-session affinity with an RSS curve) while this was written. Its report was not available at the time of writing; the numbers above are this document's own and stand on their own. Where the two disagree, prefer whichever run is reproducible on the reader's hardware, and treat the ratios (warm-vs-pool ≈ 12x, snapshot-vs-cold ≈ 2.5x, ~2–3 MB per retained runtime) as the durable claims rather than the absolute microsecond figures.
Where the security boundary belongs
This is the reframing that does most of the work, so it is worth stating precisely rather than gesturing at.
In the consuming shape this is designed for — a host application with per-user sessions, where a session is a conversation and tool-calling executions happen inside it — the trust boundary is the session, not the individual execution. Within one session:
- Every execution is the same user's code, run on that user's behalf, with that user's tools bound.
- Earlier executions in the session are that user's own earlier code. State surviving from call 1 to call 2 is not a leak; for an agent building up a computation across turns it is frequently the desired behaviour.
- The host already has to treat the whole session as one unit for authorization anyway — the tools bound into the runtime carry that user's permissions.
Across sessions, the boundary is hard: a runtime that served session A must never serve session B. Different users, different tool permissions, different data.
So the correct architecture is affinity, not pooling: a map from session
id to a warm Runtime, created on the session's first tool call and
destroyed when the session ends or is evicted. A fresh Runtime is V8's own
isolation boundary done properly — new Isolate, new Context, new op
registry — and costs 3.14 ms once per session, or 1.25 ms with a snapshot.
Honestly assessed, this is the answer, and its costs are:
| Cost | Size | Mitigation |
|---|---|---|
| Memory per retained session | ~2.3–2.9 MB | K is bounded by LRU; 64 sessions ≈ 150–190 MB |
| OS thread per retained session | 1 (each Runtime pins its own thread, runner.rs:1945+) |
Genuinely parked as of 0.2.0 (0.0% idle CPU at every K measured); same LRU bound as memory |
| Cold-start penalty on a session's first call | 3.14 ms → 1.25 ms with a snapshot | Increment 3 below |
| Cold-start penalty again after eviction | same | Tune TTL to session-idle reality; a miss is 3.14 ms, not an error |
| Cross-session leakage risk | none by construction | Never share a Runtime across session ids |
The thread-per-runtime cost deserves emphasis because it is the term that
actually binds. Runtime is not a lightweight object: it owns a dedicated
OS thread for the life of the isolate (the same pinning pool.rs documents
for pooled isolates, for the same !Send reason). K=64 retained sessions
means 64 parked threads. That is fine; K=10,000 is not. Any host expecting
very high session counts with very sparse activity should pick a small K and
accept cold starts on miss, which is precisely what an LRU gives.
This paragraph was wrong when first written, in the way that mattered most,
and the correction is the reason peno 0.2.0 exists. It asserted 64 parked
threads. They were not parked. RuntimeDispatcher::run selected between
cmd_rx.recv() and tokio::task::yield_now(), and because yield_now is
always immediately ready the loop never blocked — so each retained Runtime
burned CPU continuously for its entire lifetime whether or not it had work.
Measured on the same 8-core machine as everything else here: 12.9% of a core
per idle runtime, 169.9% at K=8, and 684% — the machine saturated — by K=64.
Worse, per-call latency degraded ~2.3x (14.5 µs → 29.5-35 µs) once the spinning
threads outnumbered the cores, so the session-affinity recommendation this
document makes was in practice capped at roughly cores-minus-one concurrent
sessions, not by memory or by thread count at all.
Fixed in 0.2.0. The dispatcher now parks on cmd_rx.recv() when the event
loop is drained and no job is queued or active, and while async work is in
flight it waits on a real waker (deno_core signals it on op/promise progress,
replacing a noop_waker that discarded every wake) bounded by the active job's
exact deadline. Re-measured: 0.0% idle CPU at K = 1, 4, 8, 16, 32 and 64, and
per-call latency flat at ~9.8 µs across that whole range — faster even at
K=1, since the spinning thread had been competing with the calling Python
thread. Full before/after tables, including the async paths, are in
BENCHMARKS.md; tests/test_idle_cpu.py is the permanent regression test.
So the scaling ceiling for session affinity is now what this document always claimed it was — thread count and memory, bounded by an LRU — rather than the core count. The recommendation stands unchanged; it just actually works now.
Termination is now bounded for every stuck shape
Parking the dispatcher exposed a second, older hole worth stating here, because
a session-affinity host is exactly the thing that needs to trust it. A retained
Runtime has to be killable on demand, and TerminationHandle.terminate() —
the only kill switch callable from a watchdog thread — could not kill a runtime
parked on a pending promise. It flips a flag and calls V8's
terminate_execution(), and V8 only acts on that when it next enters
JavaScript; a drained-but-pending event loop never does. Nothing in the
dispatcher read the flag, so new Promise(() => {}) was unkillable and the
caller blocked for the life of the process. This was measured identically
against 0.1.0's busy-spin loop, so it was never a parking regression — just a
gap that parking made impossible to keep ignoring.
Also fixed in 0.2.0. The dispatcher checks the termination flag between
polls, which kills all three parked shapes in ~1.7–2.1 ms median (worst case
3.4 ms) instead of never. while(true){} still dies by V8 unwinding JS directly
at ~0.12 ms and does not pay for the poll interval, and a politely killed
runtime keeps its bound host functions and globals — a timeout= never costs
you your runtime. timeout= itself was already honest on parked promises and is
unchanged at ~302 ms for a 300 ms limit.
The one case no polite tier can reach is a runtime whose thread is wedged
inside a host callback that never returns. RuntimeConfig(force_kill_grace=...)
bounds that too, raising RuntimeForceKilled and abandoning the thread, but it
is opt-in: it costs ~10% per synchronous call, and it cannot reclaim the isolate,
only give up on it. If you run untrusted Python callbacks behind your tools,
set it; if your host functions are your own code, the polite tiers are enough.
See BENCHMARKS.md for the numbers and
tests/test_parked_termination.py for the regression tests.
Can global-state reset ever be trusted?
Position: no. Do not build a reset API. It is a trap, and the measurement below is what a "looks clean but leaks" failure actually looks like.
Two separate claims, because they have different reasons.
Within a session, reset is not needed. It is the same user's own earlier code. Clearing it is at best a no-op for safety and at worst destroys legitimate continuity. Skip it.
Across any boundary, reset cannot be made complete. The honest reset is
close() and a new Runtime. To make this concrete rather than
hand-waved, here is the naive reset — delete every own key of globalThis,
which is what a first implementation reaches for — applied after a session
polluted the runtime seven different ways:
| Session-1 pollution | Survived the wipe? |
|---|---|
Object.prototype.pwned = 'proto-leak' |
yes — ({}).pwned still 'proto-leak' |
Array.prototype.sneak = function(){} |
yes — still a function |
JSON.stringify hijacked to wrap output |
yes — still returns 'HIJACKED:{"a":1}' |
| Pending microtask setting a global later | yes — fired after the wipe |
globalThis.secret = 'session1-token' |
yes — typeof secret still 'string' |
| Closure captured by a bound function | holder unreachable, captured value never freed |
| The runtime's own usability | destroyed — next eval: ReferenceError: Array is not defined |
The last row is the punchline. The naive reset simultaneously fails to
clear prototype pollution and intrinsic hijacking and breaks the
runtime, because deleting own keys of globalThis also deletes the
intrinsic bindings (Array, globalThis itself) that everything including
the bound tool surface depends on. A fresh Runtime control run shows
typeof ({}).pwned === 'undefined' — genuinely clean.
A sophisticated reset could do better than the naive one. It could not do
well enough. To be sound it would have to restore every mutable intrinsic
(Object.prototype, Array.prototype, Function.prototype, JSON,
Promise, Reflect, every getter/setter on every builtin), drain the
isolate-wide microtask queue, discard the module registry, and stay correct
across V8 upgrades that add new intrinsics — i.e. re-implement
v8::Context::New, in JS, without V8's help, and keep it correct forever.
V8 already ships that function. Use it: it is called making a new Context,
and for a runtime that must also carry ops, the supported way to get one is
a new Runtime.
There is no reset() on Runtime on main today (only close(),
python/runtime.rs:169). That is the correct state. This design
recommends keeping it that way and documenting why, so the absence reads
as a decision rather than an omission — exactly the treatment pool.rs
already gives "pooled isolates cannot host ops, period, not 'not yet'".
Snapshots: the other lever
This is the one place the design found more headroom than expected, and it is independently useful regardless of the affinity work.
Measured, on main's API:
Runtime(RuntimeConfig(snapshot=…))+ one eval: 1.25 ms, against 3.15 ms unsnapshotted. ~60% of cold start recovered, 2.5x.- Ops survive snapshotting. On a snapshot-started runtime,
bind_functionworks, the bound tool returns the right value, the snapshot's own bootstrap globals are present,register_op(..., mode="async")registers cleanly, and warm tool calls run at 11.0 µs — slightly faster than on an unsnapshotted runtime. - Snapshot-started runtimes are also slightly lighter: 2.33 MB/session versus 2.91 MB at K=64.
- The heavier the bootstrap, the better the trade: a 200 KB bootstrap costs 7.06 ms from source and 2.27 ms from a snapshot (3.1x).
Why ops survive, stated carefully because the mechanism matters for the
residual risk: SnapshotBuilder::create_runtime() (snapshot.rs:63-83)
builds JsRuntimeForSnapshot::try_new(RuntimeOptions { is_main: true,
..Default::default() }) — with no extensions at all, while real runtimes
are built with extensions: vec![python_extension(registry)]
(runner.rs:1954, runner.rs:2000). So the snapshot carries only a
bare-bootstrap JS heap, and the two-op shim is installed at runtime startup
on top of it. There is no external-reference mismatch because the snapshot
never contained op bindings to mismatch against. That is why it works, and
it also bounds what to promise: snapshotting with python_extension
registered — to bake the op shim JS into the snapshot too — is a different,
untested operation that would require the runtime's external reference table
to match the builder's exactly, and it is not part of this design.
The architecture is unusually friendly to this, and it is worth naming:
peno has exactly two static ops (op_peno_call_python_sync,
op_peno_call_python_async, ops.rs:132/ops.rs:170), and every
"tool" is a dynamic entry in a PythonOpRegistry dispatched through them.
The registry lives in Rust OpState, not in the V8 heap. So the op
surface is fixed and snapshot-compatible while the tool set stays fully
dynamic and per-session. Tools never needed to be in the snapshot.
Two constraints to design around:
snapshotandbootstrapare mutually exclusive (config.rs:305,:396,:453). Per-session dynamic setup therefore has to be anevalafter startup, not abootstrapstring. Cheap — that is a 3.77 µs eval.RuntimeConfigstoresOption<Vec<u8>>(config.rs:196) andsnapshot()clones the whole buffer (config.rs:440);runner.rs:1995then doesSnapshotSource::from_vec. Building a per-sessionRuntimeConfig(snapshot=…)copies 721 KB each time. At K=64 that is ~46 MB of duplicated identical bytes. The measured RSS above already includes this and is still only 2.33 MB/session, so it is an optimisation and not a blocker — but sharing one buffer (Arc<[u8]>Rust-side) is the obvious follow-up if K ever gets large.
On the prior will_snapshot finding: contributing/upstream-divergence.md (section 3) established
that will_snapshot = true routes isolate creation through V8's
SnapshotCreator, which appears to force synchronous compilation and is why
the large-script SIGABRT never reproduced on the builder path. That finding
is about snapshot build time, which this design pays once, offline, at
15–39 ms. It does not touch snapshot consumption, which is the 1.25 ms
number and the only part on the request path. The two are unrelated costs
and should not be conflated.
The deno_core fork, priced
Vendoring deno_core to make JsRealm public would enable one JsRuntime
to multiplex many Contexts — true "pooled ops". Priced honestly, and not
declined reflexively; this project has forked a crate before when the
alternative was worse (contributing/upstream-divergence.md, section 1).
What it would buy: fresh-Context-per-call with ops available. By
construction that lands at roughly pool cost — the 167 µs checkout is
dominated by Context construction plus a channel hop, and adding op
binding to it does not make it cheaper. So the fork's deliverable is a
167 µs tool call, to replace a 13.4 µs one that works today.
That is the whole verdict: the fork is not merely expensive, it is
pointed the wrong way. It would make the ops path ~12x slower than the
recommendation in this document, in exchange for cross-session isolation
that per-session affinity already provides for free — and it would provide
it worse, since a shared isolate keeps every session's dead contexts on
one heap (the exact accumulation pool.rs measures at 130 MB per isolate
over 3000 contexts, and mitigates with low_memory_notification()).
The fork's only genuine advantage is memory density: one isolate serving N sessions' contexts costs less than N isolates. At ~2.3 MB/session that trade starts to matter somewhere north of a thousand concurrent retained sessions — and a host at that scale has a cheaper answer available first (smaller K, shorter TTL, accept cold starts), which costs 1.25 ms on a miss instead of a permanent 12x latency regression on every hit.
What maintenance would cost, for the record:
deno_corereleases roughly every 2–4 weeks tracking Deno's V8 bumps. peno is pinned atdeno_core0.409.0 /v8150.4.0. A vendored fork means re-applying theJsRealmvisibility change on every bump the project wants to take, forever, or freezing on 0.409 and forgoing V8 security fixes — a bad look for a sandbox.- The change is not a one-line
pub.JsRealmispub(crate)because its invariants are maintained by surrounding private bookkeeping; exposing it means either exporting that bookkeeping too or owning the correctness of realm lifecycle by hand. That is the "fast but leaky" shortcutpool.rsalready declined. - V8 upgrades land precisely in this area (context/realm/snapshot internals churn between V8 majors), so the upgrade tax concentrates on the forked surface rather than being spread thinly.
- The existing fork this project maintains (
contributing/upstream-divergence.md, section 1) is a narrow, additive termination-handle change.JsRealmvisibility is a structural one. They are not comparable precedents.
Recommendation: do not fork. Revisit only if a consuming host demonstrates >1000 concurrent retained sessions and per-session memory is the binding constraint and a 12x tool-call latency regression is acceptable. Those conditions are unlikely to co-occur.
What to build
Cheapest useful increment first. Increments 1 and 2 are the "95% of the value for 5% of the work" the summary promises; 3 is a real but optional win; 4 and 5 are explicit non-goals recorded so they stop being re-litigated.
Increment 1 — Document and benchmark the fast path that already exists (no new code)
The single highest-value change in this document requires no feature work,
because the fast path already ships. What is missing is that nobody can
tell: BENCHMARKS.md advertises pooling at 16–18x and never benchmarks
"retain the runtime and call the tool again", so a reader reasonably
concludes the ops path is the slow one.
- Add two rows to
BENCHMARKS.md: warmRuntimefull tool call (13.4 µs) and coldRuntime+ bind + one tool call (3.14 ms), next to the existing pooled checkout row, with one sentence making the comparison explicit — a warm runtime's tool call is ~12x faster than a pooled eval that cannot call tools at all. - Add the corresponding
benches_py/case so the number is defended against regression, not just asserted once. - Add a short note to
pool.rs's module docs — which already says pooled isolates cannot host ops, "period, not 'not yet'" — recording why that is fine: the ops path's amortised cost is an order of magnitude below the pool's, so the missing capability is not a performance gap.
This is hours of work, changes no behaviour, and is what stops the next
contributor from designing a JsRealm fork.
Increment 2 — Session-affine runtime registry
A small, pure-Python keyed cache in python/peno/. No Rust.
SessionRuntimes(max_sessions=K, idle_ttl=…, factory=…):get(session_id)returns the session's warmRuntime, creating it on first use and binding that session's tools via the factory.- LRU eviction at K, plus idle-TTL expiry; eviction calls
close(). - A
Runtimeis never keyed by anything but session id, and never re-keyed. That single invariant is the whole security argument, so it should be the thing the tests assert hardest: a runtime handed out for session A is never handed out for session B, and an evicted session's next call gets a genuinely fresh runtime (asserttypeof ({}).pwned === 'undefined'after a deliberately-polluting session is evicted — the same leakage test shapetests/test_isolate_pool.pyalready uses for the pool). - Document the cold-start-on-miss behaviour as a latency characteristic (3.14 ms, or 1.25 ms after increment 3), not an error path.
Deliberately not in scope: any reset, any cross-session reuse, any attempt to make eviction return memory promptly (see deferrals).
Increment 3 — Snapshot the static bootstrap
Independent of 1 and 2; do it when the first-call latency of a session actually shows up as a complaint.
- Build one process-wide snapshot at startup covering the static JS the host wants in every session (the tool-bridge shim's JS-side helpers, polyfills), and start every session runtime from it: 3.15 ms → 1.25 ms.
- Keep per-session dynamic setup as a post-startup
eval, sincesnapshotandbootstrapare mutually exclusive. - Share one snapshot buffer rather than cloning 721 KB per
RuntimeConfig(config.rs:196,:440). - Do not attempt to snapshot with
python_extensionregistered. The measured, working configuration is an extension-free snapshot with the op shim installed at runtime; baking the shim in is a separate exercise in external-reference matching with no measured benefit.
Increment 4 — Deferred: reset()
Do not build. Covered above: unnecessary within a session, impossible across one. The deliverable here is documentation of the refusal — a short note in the architecture docs and the measured pollution-survival table — so the absence is legible.
Increment 5 — Deferred: pooled ops / JsRealm fork
Do not build. Covered above. Revisit only under the three simultaneous conditions listed in the fork section.
Deferrals and open questions
Stated explicitly rather than buried, for the record.
- Does eviction actually return memory? Untested. The RSS figures are
high-water marks and cannot answer it.
close()should drop the isolate and join the thread, but the pool's own experience — dead contexts needing an explicitlow_memory_notification()to be reclaimed — is a warning that "unreachable" and "freed" differ in V8. If a host runs a long-lived process with heavy session churn, measure steady-state RSS across many create/evict cycles before trusting K as a memory bound. This is the most likely place this design is wrong. - K and TTL have no recommended defaults here, because the right values are a property of the host's session-arrival distribution, not of peno. The registry should make them explicit constructor arguments with no clever defaults.
- Thread count, not memory, is the scaling ceiling. Partly measured now.
The original busy-spin made the core count the real ceiling; 0.2.0 removed
that (0.0% idle CPU and flat per-call latency through K=64, see
BENCHMARKS.md), so K=64 retained runtimes is now demonstrably cheap. Where K parked threads does start to hurt scheduling is still unmeasured — K=64 is the largest value tested, and nothing here justifies a K in the thousands. - Concurrency within a session is out of scope. This design assumes
sequential tool calls within a session, which matches the measured
test_steady_state_*shape. Two concurrent calls on one session's runtime serialize on its thread; whether that needs addressing is a host-shaped question, not answered here. - The absolute microsecond figures are one run on one machine. The
ratios are the durable claims. Re-run before making a regression decision
on different hardware, per
BENCHMARKS.md's standing warning. - Pre-existing environment flake.
BENCHMARKS.mddocuments an intermittent V8-internalSIGABRT/tokio-context panic under heap-limit and high-concurrency scenarios ondeno_core0.409 /v8150.4.0, unrelated to this work. A session registry holding many runtimes raises concurrency, so that flake may surface more often; it should be tracked as the known issue it already is rather than mistaken for a bug in the registry.
Mechanisms this design relies on
Every mechanism this design depends on ships in peno 0.1.0, so nothing here
is blocked on future work (though 0.2.0 or later is strongly preferable — see
the busy-spin correction above, which is what makes retaining many runtimes
viable at all):
- Required:
bind_function/register_op, the two static ops (ops.rs),SnapshotBuilder(snapshot.rs), andRuntimeConfig(snapshot=…)(config.rs). Increments 1–3 can be built against the current API as-is. - Helpful:
ToolBridgeis the natural thing for the session registry's factory to construct per session (call budget, fail-closed name checking), and typed tool errors plus console capture make a long-lived session runtime far more debuggable than a barebind_functionsurface. Increment 2's factory should take aToolBridgerather than rawbind_functioncalls. - Irrelevant to this design: the
low_memory_notification()memory fix inpool.rs. It applies to pooled isolates recycling contexts, and this design does not pool. It is, however, the best available evidence for the open question about whether eviction returns memory.