lex_bytecode/vm/mod.rs
1//! M5: bytecode VM. Stack machine with effect dispatch through a host handler.
2
3use crate::op::*;
4use crate::program::*;
5use crate::value::{ActorCell, Value};
6use std::sync::{Arc, Mutex, OnceLock};
7use indexmap::IndexMap;
8use smol_str::SmolStr;
9use std::collections::{HashMap, VecDeque};
10
11mod closures;
12mod dispatch;
13mod memo;
14mod native_list;
15
16use memo::*;
17use native_list::*;
18
19// ── IC polymorphism instrumentation (throwaway, env-gated) ─────────
20// Enable with LEX_IC_STATS=1. With LEX_IC_STATS_OUT=<path> writes a
21// TSV to <path>.<pid> on each Vm drop; otherwise dumps to stderr.
22
23#[derive(Default)]
24struct IcStats {
25 sites: HashMap<(u32, u32), HashMap<u32, u64>>,
26}
27
28static IC_STATS: OnceLock<Mutex<IcStats>> = OnceLock::new();
29static IC_STATS_ENABLED: OnceLock<bool> = OnceLock::new();
30
31fn ic_stats_enabled() -> bool {
32 *IC_STATS_ENABLED.get_or_init(|| {
33 std::env::var("LEX_IC_STATS").map(|v| v == "1").unwrap_or(false)
34 })
35}
36
37fn record_ic_hit(fn_id: u32, site_idx: u32, shape_id: u32) {
38 let stats = IC_STATS.get_or_init(|| Mutex::new(IcStats::default()));
39 let mut s = stats.lock().unwrap();
40 *s.sites.entry((fn_id, site_idx)).or_default().entry(shape_id).or_insert(0) += 1;
41}
42
43pub fn dump_ic_stats() {
44 let Some(stats) = IC_STATS.get() else { return; };
45 let s = stats.lock().unwrap();
46 if s.sites.is_empty() { return; }
47 let mut out = String::from("fn_id\tsite_idx\tshape_id\thits\n");
48 let mut entries: Vec<_> = s.sites.iter().collect();
49 entries.sort_by_key(|((f, si), _)| (*f, *si));
50 for ((f, site), shapes) in entries {
51 let mut shape_entries: Vec<_> = shapes.iter().collect();
52 shape_entries.sort_by_key(|(sid, _)| **sid);
53 for (sid, hits) in shape_entries {
54 out.push_str(&format!("{f}\t{site}\t{sid}\t{hits}\n"));
55 }
56 }
57 match std::env::var("LEX_IC_STATS_OUT").ok() {
58 Some(path) => {
59 let pid = std::process::id();
60 let _ = std::fs::write(format!("{path}.{pid}"), out);
61 }
62 None => { eprint!("{out}"); }
63 }
64}
65
66#[derive(Debug, Clone, thiserror::Error)]
67pub enum VmError {
68 #[error("runtime panic: {0}")]
69 Panic(String),
70 #[error("type mismatch at runtime: {0}")]
71 TypeMismatch(String),
72 #[error("stack underflow")]
73 StackUnderflow,
74 #[error("unknown function: {0}")]
75 UnknownFunction(String),
76 #[error("effect handler error: {0}")]
77 Effect(String),
78 /// The program called `std.process.exit(code)` (#754).
79 ///
80 /// Not a failure — a deliberate signal — but it travels the error
81 /// channel because that is the VM's only unwind path, and unwinding
82 /// is the point: the alternative is calling `std::process::exit`
83 /// inside the handler, which would terminate the process where it
84 /// stands and silently skip everything `lex run` does *after* the
85 /// call returns (finalising the trace, writing the `Trace`
86 /// attestation, recording committed ops). A run that exits is still
87 /// a run that happened, and it should leave the same evidence.
88 #[error("process exit requested: {0}")]
89 ProcessExit(i32),
90 #[error("call stack overflow: recursion depth exceeded ({0})")]
91 CallStackOverflow(u32),
92 /// Refinement predicate failed at a call boundary (#209 slice 3).
93 /// Surfaced when a function declares `param :: Type{x | predicate}`,
94 /// the call-site arg couldn't be discharged statically (slice 2),
95 /// and the runtime evaluator finds the predicate is `false` for
96 /// the actual argument value. The `verdict` mirrors the shape of
97 /// `gate.verdict`-style records in `lex-trace`.
98 #[error("refinement violated: argument {param_index} of `{fn_name}` (binding `{binding}`): {reason}")]
99 RefinementFailed {
100 fn_name: String,
101 param_index: usize,
102 binding: String,
103 reason: String,
104 },
105 /// Integer division or modulo with a zero divisor (#696). Without
106 /// this guard the host `/`/`%` panics and takes the whole process
107 /// down — the crash report had a conformance harness compute a
108 /// rate over an empty set in teardown, far from any user-visible
109 /// division. Surfacing a catchable `VmError` instead keeps the
110 /// failure inside the language's error model. Float div/mod is
111 /// exempt: IEEE-754 yields inf/NaN rather than trapping.
112 #[error("integer {op} by zero")]
113 DivByZero {
114 /// `"division"` or `"modulo"` — names the offending operator.
115 op: &'static str,
116 },
117}
118
119/// Maximum simultaneous call frames. Defends against unbounded
120/// recursion in agent-emitted code: a body that calls itself
121/// without a base case would otherwise blow the host's native
122/// stack and crash the process. Real Lex code rarely exceeds
123/// ~30 frames; 1024 is generous headroom while still well under
124/// the OS stack limit at any per-frame size we use.
125pub const MAX_CALL_DEPTH: u32 = 1024;
126
127/// Per-frame stack-record budget (#464 step 2). Counts the number of
128/// `Value` slots a frame may consume from `Vm::stack_record_arena`
129/// before further `Op::AllocStackRecord` requests fall back to the
130/// heap path. 64 slots at the current `size_of::<Value>() = 64B`
131/// gives ~4 KiB per frame, matching the design-doc proposal in
132/// `docs/design/escape-analysis.md`. A handler-shaped function
133/// (one outer record of ≤8 fields, plus a handful of small inner
134/// records) fits well inside this without growing.
135pub const STACK_RECORD_BUDGET_SLOTS: u32 = 64;
136
137/// Host-side effect dispatch. Implementors decide what `kind`/`op` mean
138/// and how arguments map to side effects.
139pub trait EffectHandler {
140 fn dispatch(&mut self, kind: &str, op: &str, args: Vec<Value>) -> Result<Value, String>;
141
142 /// Hook called by the VM at every function call so handlers can
143 /// enforce per-call budget consumption (#225). The argument is
144 /// the sum of `[budget(N)]` declared on the callee's signature;
145 /// the handler returns `Err` to refuse the call (the VM converts
146 /// to `VmError::Effect`). Default impl is a no-op so legacy
147 /// handlers and pure-only runs are unaffected.
148 fn note_call_budget(&mut self, _budget_cost: u64) -> Result<(), String> {
149 Ok(())
150 }
151
152 /// Has the program asked to terminate, and with what status (#754)?
153 ///
154 /// Checked by the VM after every effect dispatch. A handler that
155 /// supports `std.process.exit` records the requested code and
156 /// returns it here **once**; the VM then unwinds with
157 /// [`VmError::ProcessExit`].
158 ///
159 /// A hook rather than a richer error type on `dispatch` for the
160 /// same reason `note_call_budget` is one: the default impl keeps
161 /// every existing handler compiling and behaving exactly as before.
162 /// Signalling through the `String` error channel was the
163 /// alternative, and a sentinel string is not a contract.
164 fn take_exit(&mut self) -> Option<i32> {
165 None
166 }
167
168 /// Enter a per-request allocation scope (#463 scaffolding).
169 /// Called by the runtime layer (e.g. `net.serve_fn`'s request
170 /// loop) immediately before invoking the user handler closure
171 /// for one request. Implementations push a fresh arena onto
172 /// their internal stack and return its identifier; the matching
173 /// `exit_request_scope` call drops it.
174 ///
175 /// Default impl is a no-op — handlers without arena support
176 /// return a sentinel scope id which they ignore on exit.
177 /// `DefaultHandler` in `lex-runtime` provides the real
178 /// implementation.
179 ///
180 /// Today the VM does NOT route any `Value` allocations through
181 /// the returned arena — see the scaffolding notes in
182 /// `crates/lex-runtime/src/arena.rs`. The hook exists so the
183 /// follow-on slice that adds Value-rep arena routing has a
184 /// stable trait surface to extend.
185 fn enter_request_scope(&mut self) -> u64 { 0 }
186
187 /// Exit a per-request allocation scope opened by
188 /// `enter_request_scope`. Implementations drop the arena
189 /// associated with `scope_id`. Calling exit with a scope_id
190 /// that wasn't returned by a prior enter is implementation-
191 /// defined behavior — DefaultHandler treats it as a no-op so
192 /// mismatched pairs don't panic.
193 fn exit_request_scope(&mut self, _scope_id: u64) {}
194
195 /// `list.par_map` worker-handler factory (#305 slice 2).
196 ///
197 /// Each parallel worker thread runs its own `Vm` and therefore
198 /// needs its own effect handler. The parent handler may opt in
199 /// to per-worker dispatch by returning `Some(handler)` here;
200 /// returning `None` (the default) keeps slice-1 behavior: the
201 /// worker runs `DenyAllEffects` and any effect call inside the
202 /// closure fails with `VmError::Effect`.
203 ///
204 /// The returned handler must be `Send` so the worker can take
205 /// ownership across a thread boundary. Shared state (budget
206 /// pool, chat registry, etc.) is wired up by the implementer.
207 /// Per-worker independence (MCP client cache, output sink)
208 /// is intentional — the alternative is mutex-serialization of
209 /// the whole effect dispatch, which would defeat the parallelism.
210 fn spawn_for_worker(&self) -> Option<Box<dyn EffectHandler + Send>> {
211 None
212 }
213}
214
215/// A handler that fails any effect call. Useful as a default for pure-only runs.
216pub struct DenyAllEffects;
217impl EffectHandler for DenyAllEffects {
218 fn dispatch(&mut self, kind: &str, op: &str, _args: Vec<Value>) -> Result<Value, String> {
219 Err(format!("effects not permitted (attempted {kind}.{op})"))
220 }
221}
222
223/// Trace receiver. Implementors record the call/effect tree and may
224/// substitute effect responses (for replay).
225pub trait Tracer {
226 fn enter_call(&mut self, node_id: &str, name: &str, args: &[Value]);
227 fn enter_effect(&mut self, node_id: &str, kind: &str, op: &str, args: &[Value]);
228 fn exit_ok(&mut self, value: &Value);
229 fn exit_err(&mut self, message: &str);
230 /// Tail-call optimization: pop the current frame's open call without
231 /// re-entering the parent (the new call takes its place).
232 fn exit_call_tail(&mut self);
233 /// During replay, return Some(v) to substitute an effect's output.
234 fn override_effect(&mut self, _node_id: &str) -> Option<Value> { None }
235}
236
237/// No-op tracer for normal execution.
238pub struct NullTracer;
239impl Tracer for NullTracer {
240 fn enter_call(&mut self, _: &str, _: &str, _: &[Value]) {}
241 fn enter_effect(&mut self, _: &str, _: &str, _: &str, _: &[Value]) {}
242 fn exit_ok(&mut self, _: &Value) {}
243 fn exit_err(&mut self, _: &str) {}
244 fn exit_call_tail(&mut self) {}
245}
246
247#[derive(Debug, Clone)]
248pub(crate) enum FrameKind {
249 /// Top-level entry frame; doesn't correspond to a Call opcode.
250 Entry,
251 /// Frame opened by Call/TailCall. The `String` is the originating
252 /// `NodeId`; useful for diagnostics even if currently unread.
253 Call(#[allow(dead_code)] String),
254}
255
256pub struct Vm<'a> {
257 program: &'a Program,
258 handler: Box<dyn EffectHandler + 'a>,
259 pub(crate) tracer: Box<dyn Tracer + 'a>,
260 /// Per-call frames. Each frame has its own locals array and pc.
261 frames: Vec<Frame>,
262 stack: Vec<Value>,
263 /// Soft cap to avoid runaway computations in tests.
264 pub step_limit: u64,
265 pub steps: u64,
266 /// Per-Vm memoization cache for pure functions (#229). Keyed by
267 /// `(fn_id, hash_call_args(args))` — a 128-bit structural digest
268 /// of the arguments (see `hash_call_args`). Effectful functions
269 /// never enter this map. The cache lives for the lifetime of one
270 /// `Vm::call` chain — calling `Vm::with_handler` again starts a
271 /// fresh cache.
272 pure_memo: std::collections::HashMap<(u32, [u8; 16]), Value>,
273 /// Diagnostic counters for `--trace` observability (#229).
274 pub pure_memo_hits: u64,
275 pub pure_memo_misses: u64,
276 /// Number of effect-free calls that skipped the cache entirely
277 /// because adaptive memoization disabled their function (#229
278 /// adaptive). Observability only.
279 pub pure_memo_skips: u64,
280 /// Adaptive-memoization state, one entry per function (indexed by
281 /// `fn_id`), parallel to `field_ics` (#229 adaptive). Memoization
282 /// only pays when a function is called repeatedly with equal args;
283 /// the unconditional `hash_call_args` on every effect-free call is
284 /// pure overhead otherwise (the `response_build` profile: 0 hits /
285 /// 3600 misses, ~12% of instructions). After a warmup window with
286 /// zero hits we stop hashing that function's calls — always safe,
287 /// since the callee is pure and recomputing yields the same value.
288 /// Sticky for the Vm's lifetime: a function that hasn't hit in
289 /// `MEMO_WARMUP_CALLS` calls won't amortize later.
290 memo_fn_state: Vec<MemoFnState>,
291 /// Monomorphic inline caches for `Op::GetField` (#462 slice 1 +
292 /// shape-keyed verification slice). Indexed by
293 /// `[fn_id as usize][site_idx as usize]` — one entry per
294 /// field-access site within each function. `site_idx` is assigned
295 /// at compile time by `FnCompiler::field_get_sites` so every emit
296 /// produces a stable identifier independent of pc. The cache
297 /// survives the planned dispatch rewrite (#461) and a future
298 /// JIT (#465).
299 ///
300 /// Slot shape: `(shape_id, offset)`. The pre-shape-keyed slice
301 /// stored only the offset and re-verified each hit by walking
302 /// `IndexMap::get_index(off)` and string-comparing the field name
303 /// against the requested `name_idx`. After this slice, hits
304 /// against compile-time records (real `shape_id`) verify with a
305 /// single `u32` compare and skip the string compare entirely —
306 /// per the #462 slice-2b measurement that observed 0% polymorphism
307 /// and 86% of hits going to records with a real shape_id.
308 ///
309 /// `NO_SHAPE_ID` records (JSON / SQL / HTTP-built — 14% of measured
310 /// hits, 100% of inbox/gateway traffic) fall through to the
311 /// pre-slice name-compare verification. Distinct dynamic shapes
312 /// both carry `NO_SHAPE_ID` and would otherwise alias on a
313 /// pure-shape-keyed IC; keeping the name compare on that path
314 /// preserves correctness without a separate cache for them.
315 ///
316 /// Outer Vec is pre-sized to `program.functions.len()`; each inner
317 /// Vec is empty until the first GetField in that function runs,
318 /// at which point we one-shot allocate it to the compiler-recorded
319 /// `field_ic_sites` size and never resize again. Lazy on the inner
320 /// side so VMs created for short-lived scripts don't eagerly
321 /// allocate IC slots for functions they never enter.
322 field_ics: Vec<Vec<Option<(u32, usize)>>>,
323 /// Stack allocator for function locals (#389 slice 3).
324 ///
325 /// Every function frame claims `locals_count` contiguous slots from
326 /// this Vec on push and releases them on pop. Because Lex uses
327 /// strictly LIFO frame semantics the most-recently-pushed frame's
328 /// slots always sit at the top of the Vec, so `truncate` is the
329 /// correct (and O(1)) release operation.
330 ///
331 /// The Vec is pre-allocated once at VM construction and then grows
332 /// only if the actual call depth × locals width exceeds the initial
333 /// capacity. After a top-level `vm.call` returns the Vec is empty
334 /// again but its capacity is retained, so the next request incurs
335 /// zero allocations for locals up to the high-water mark.
336 locals_storage: Vec<Value>,
337 /// Stack-record arena (#464 step 2). Each `Op::AllocStackRecord`
338 /// at a non-escaping site appends its `field_count` field values
339 /// here; the produced `Value::StackRecord` carries `slab_start =
340 /// arena.len() - field_count` so reads are an O(1) slab index.
341 /// On `Op::Return` the arena is truncated back to
342 /// `frame.stack_record_arena_start`, releasing every record the
343 /// frame allocated in O(1) — same lifetime story as
344 /// `locals_storage` for frame locals.
345 ///
346 /// LIFO frame discipline guarantees a frame's records always sit
347 /// at the top of the arena while the frame is live, so neither
348 /// inter-frame interleaving nor index churn can occur.
349 stack_record_arena: Vec<Value>,
350 /// Per-Vm counters for #464 acceptance measurement. Incremented
351 /// on every `Op::MakeRecord` / `Op::AllocStackRecord` dispatch.
352 /// The bench reads these to compute the stack-allocation rate
353 /// (≥ 60% of records on the stack is the acceptance bar). Cheap
354 /// in the hot path — two unconditional u64 increments per record.
355 pub stack_record_allocs: u64,
356 pub stack_record_heap_fallbacks: u64,
357 pub heap_record_allocs: u64,
358 /// Request-scoped arena slab (#463 slice 2a). Mirrors the shape of
359 /// `stack_record_arena` but lives across frames inside the
360 /// request scope opened by `EffectHandler::enter_request_scope`.
361 /// Each `Op::AllocArenaRecord` / `Op::AllocArenaTuple` appends its
362 /// field values here and pushes a handle (`Value::ArenaRecord` /
363 /// `Value::ArenaTuple`) whose `slab_start` indexes back in.
364 /// Truncated to the saved start on `exit_request_scope`, releasing
365 /// every value the scope built in O(1) — same lifetime story as
366 /// `stack_record_arena` truncating on `Op::Return`.
367 ///
368 /// Slabs nest LIFO: `arena_scope_starts` holds the
369 /// `arena_slab.len()` snapshot taken at each `enter_request_scope`,
370 /// and `exit_request_scope` truncates back to the matching entry.
371 /// An empty `arena_scope_starts` means **no active scope** — the
372 /// alloc ops fall back to their `MakeRecord` / `MakeTuple` heap
373 /// path, so the VM stays sound when arena-lowered bytecode runs in
374 /// a non-handler context.
375 arena_slab: Vec<Value>,
376 /// LIFO stack of `arena_slab.len()` snapshots, one per active
377 /// request scope. See `arena_slab`.
378 arena_scope_starts: Vec<u32>,
379 /// Counters for #463 slice-2b acceptance (will be the
380 /// arena-allocation-rate gate, paralleling the #464 stack-rate
381 /// counters above). Incremented in the op handlers; harmless in
382 /// slice 2a since codegen doesn't emit the ops yet.
383 pub arena_record_allocs: u64,
384 pub arena_record_heap_fallbacks: u64,
385 /// Optional JIT tier hook (#465 phase-1 integration). Consulted
386 /// by the `Op::Call` dispatch arm after refinements + memo. See
387 /// `crate::jit_hook` for the trait contract. `None` means
388 /// "interpreter-only" — that branch in the dispatch arm folds
389 /// to a single null-pointer check the optimizer can hoist.
390 jit_hook: Option<Box<dyn crate::jit_hook::JitHook + 'a>>,
391}
392
393struct Frame {
394 fn_id: u32,
395 pc: usize,
396 /// Start index of this frame's locals in `Vm::locals_storage` (#389
397 /// slice 3). The frame owns `locals_storage[locals_start..locals_start
398 /// + locals_len]`; `Op::Return` truncates the Vec back to
399 /// `locals_start`, releasing the slots in O(1).
400 locals_start: usize,
401 locals_len: usize,
402 /// Stack base when this frame started (for cleanup on return).
403 stack_base: usize,
404 trace_kind: FrameKind,
405 /// Pure-fn memo key (#229). `Some(key)` if the call was eligible
406 /// for memoization and missed the cache; on Op::Return the key
407 /// is used to write the return value back into the cache.
408 /// `None` means "don't memoize" — either the function isn't pure,
409 /// the call wasn't through Op::Call, or memoization is disabled.
410 memo_key: Option<(u32, [u8; 16])>,
411 /// #464 step 2: start index of this frame's records in
412 /// `Vm::stack_record_arena`. On `Op::Return`, the arena is
413 /// truncated back here. Identical lifetime discipline to
414 /// `locals_start`.
415 stack_record_arena_start: usize,
416 /// Remaining stack-record budget for this frame, in Value-slot
417 /// units (#464 step 2). Initial value: `STACK_RECORD_BUDGET_SLOTS`.
418 /// When an `Op::AllocStackRecord` would consume more slots than
419 /// remain, the VM falls back to the heap path silently (same
420 /// observable effect as `Op::MakeRecord`), so the budget never
421 /// surfaces as a user-visible error.
422 stack_record_budget_remaining: u32,
423}
424
425/// Sum of `[budget(N)]` declarations on a function's signature
426/// (#225). Used by Op::Call / Op::TailCall / Op::CallClosure to
427/// notify the EffectHandler of per-call budget cost so the handler
428/// can deduct from a shared pool and refuse calls that would
429/// exceed the policy ceiling. Negative `Int` args are ignored —
430/// the static check (`policy::check_program`) treats budgets as
431/// non-negative.
432fn call_budget_cost(f: &crate::program::Function) -> u64 {
433 let mut total: u64 = 0;
434 for e in &f.effects {
435 if e.kind == "budget" {
436 if let Some(crate::program::EffectArg::Int(n)) = &e.arg {
437 if *n >= 0 {
438 total = total.saturating_add(*n as u64);
439 }
440 }
441 }
442 }
443 total
444}
445
446/// Evaluate a refinement predicate at runtime against the actual
447/// argument value (#209 slice 3). Mirrors `lex_types::discharge`'s
448/// static evaluator but operates on `Value` directly.
449///
450/// Returns `Ok(true)` / `Ok(false)` for a clean boolean verdict, or
451/// `Err(reason)` if the predicate references something the runtime
452/// can't resolve (free variable beyond the binding, unsupported AST
453/// node). Callers map `Ok(false)` and `Err` to `VmError::RefinementFailed`.
454fn eval_refinement(
455 predicate: &lex_ast::CExpr,
456 binding: &str,
457 arg: &Value,
458) -> Result<bool, String> {
459 match eval_refinement_inner(predicate, binding, arg) {
460 Ok(Value::Bool(b)) => Ok(b),
461 Ok(other) => Err(format!("predicate didn't reduce to a Bool, got {other:?}")),
462 Err(e) => Err(e),
463 }
464}
465
466fn eval_refinement_inner(
467 e: &lex_ast::CExpr,
468 binding: &str,
469 arg: &Value,
470) -> Result<Value, String> {
471 use lex_ast::{CExpr, CLit};
472 match e {
473 CExpr::Literal { value } => Ok(match value {
474 CLit::Int { value } => Value::Int(*value),
475 CLit::Float { value } => Value::Float(value.parse().unwrap_or(0.0)),
476 CLit::Bool { value } => Value::Bool(*value),
477 CLit::Str { value } => Value::Str(value.as_str().into()),
478 CLit::Bytes { value } => Value::Str(value.as_str().into()), // hex; unusual in predicates
479 CLit::Unit => Value::Unit,
480 }),
481 CExpr::Var { name } if name == binding => Ok(arg.clone()),
482 CExpr::Var { name } => Err(format!(
483 "predicate references free var `{name}`; runtime check \
484 only resolves the binding (slice 4 will plumb call-site \
485 context)")),
486 CExpr::UnaryOp { op, expr } => {
487 let v = eval_refinement_inner(expr, binding, arg)?;
488 match (op.as_str(), v) {
489 ("not", Value::Bool(b)) => Ok(Value::Bool(!b)),
490 ("-", Value::Int(n)) => Ok(Value::Int(-n)),
491 ("-", Value::Float(n)) => Ok(Value::Float(-n)),
492 (o, v) => Err(format!("unsupported unary `{o}` on {v:?}")),
493 }
494 }
495 CExpr::BinOp { op, lhs, rhs } => {
496 // Short-circuit `and` / `or` for the same reasons as the
497 // static evaluator.
498 if op == "and" || op == "or" {
499 let l = eval_refinement_inner(lhs, binding, arg)?;
500 let lb = match l {
501 Value::Bool(b) => b,
502 other => return Err(format!("`{op}` on non-bool: {other:?}")),
503 };
504 if op == "and" && !lb { return Ok(Value::Bool(false)); }
505 if op == "or" && lb { return Ok(Value::Bool(true)); }
506 let r = eval_refinement_inner(rhs, binding, arg)?;
507 return match r {
508 Value::Bool(b) => Ok(Value::Bool(b)),
509 other => Err(format!("`{op}` on non-bool: {other:?}")),
510 };
511 }
512 let l = eval_refinement_inner(lhs, binding, arg)?;
513 let r = eval_refinement_inner(rhs, binding, arg)?;
514 apply_refinement_binop(op, &l, &r)
515 }
516 // Other AST forms (Call, Let, Match, FieldAccess, Lambda,
517 // Block, Constructors, Records, Tuples, Lists, Return) need
518 // a more general evaluator that can call back into the VM.
519 // Out of scope for slice 3; a future slice may unify this
520 // with the spec-checker's gate evaluator.
521 other => Err(format!("unsupported predicate node: {other:?}")),
522 }
523}
524
525fn apply_refinement_binop(op: &str, l: &Value, r: &Value) -> Result<Value, String> {
526 use Value::*;
527 match (op, l, r) {
528 ("+", Int(a), Int(b)) => Ok(Int(a + b)),
529 ("-", Int(a), Int(b)) => Ok(Int(a - b)),
530 ("*", Int(a), Int(b)) => Ok(Int(a * b)),
531 ("/", Int(a), Int(b)) if *b != 0 => Ok(Int(a / b)),
532 ("%", Int(a), Int(b)) if *b != 0 => Ok(Int(a % b)),
533 ("+", Float(a), Float(b)) => Ok(Float(a + b)),
534 ("-", Float(a), Float(b)) => Ok(Float(a - b)),
535 ("*", Float(a), Float(b)) => Ok(Float(a * b)),
536 ("/", Float(a), Float(b)) => Ok(Float(a / b)),
537
538 ("==", a, b) => Ok(Bool(a == b)),
539 ("!=", a, b) => Ok(Bool(a != b)),
540
541 ("<", Int(a), Int(b)) => Ok(Bool(a < b)),
542 ("<=", Int(a), Int(b)) => Ok(Bool(a <= b)),
543 (">", Int(a), Int(b)) => Ok(Bool(a > b)),
544 (">=", Int(a), Int(b)) => Ok(Bool(a >= b)),
545
546 ("<", Float(a), Float(b)) => Ok(Bool(a < b)),
547 ("<=", Float(a), Float(b)) => Ok(Bool(a <= b)),
548 (">", Float(a), Float(b)) => Ok(Bool(a > b)),
549 (">=", Float(a), Float(b)) => Ok(Bool(a >= b)),
550
551 (op, a, b) => Err(format!(
552 "unsupported binop `{op}` on {a:?} and {b:?}")),
553 }
554}
555
556fn const_str(constants: &[Const], idx: u32) -> String {
557 match constants.get(idx as usize) {
558 Some(Const::NodeId(s)) | Some(Const::Str(s)) => s.clone(),
559 _ => String::new(),
560 }
561}
562
563impl<'a> Vm<'a> {
564 pub fn new(program: &'a Program) -> Self {
565 Self::with_handler(program, Box::new(DenyAllEffects))
566 }
567
568 pub fn with_handler(program: &'a Program, handler: Box<dyn EffectHandler + 'a>) -> Self {
569 Self {
570 program,
571 handler,
572 tracer: Box::new(NullTracer),
573 // Pre-allocate enough capacity for a typical request so the first
574 // call incurs no reallocation (#389 slice 3).
575 frames: Vec::with_capacity(32),
576 stack: Vec::with_capacity(128),
577 step_limit: 10_000_000,
578 steps: 0,
579 pure_memo: std::collections::HashMap::new(),
580 pure_memo_hits: 0,
581 pure_memo_misses: 0,
582 pure_memo_skips: 0,
583 memo_fn_state: vec![MemoFnState::default(); program.functions.len()],
584 field_ics: vec![Vec::new(); program.functions.len()],
585 // 256 slots handles ~32 frames × 8 locals; grows on demand and
586 // retains capacity across consecutive vm.call() invocations.
587 locals_storage: Vec::with_capacity(256),
588 // #464 step 2: zero capacity at construction — handlers that
589 // never AllocStackRecord (most code today, until the lowering
590 // pass kicks in) pay nothing. First allocation triggers Vec
591 // growth; capacity is retained across `vm.call` invocations.
592 stack_record_arena: Vec::new(),
593 stack_record_allocs: 0,
594 stack_record_heap_fallbacks: 0,
595 heap_record_allocs: 0,
596 // #463 slice 2a: empty until the first enter_request_scope.
597 // Programs that never enter a scope incur zero arena cost
598 // (the alloc ops, if reached, fall back to the heap path).
599 arena_slab: Vec::new(),
600 arena_scope_starts: Vec::new(),
601 arena_record_allocs: 0,
602 arena_record_heap_fallbacks: 0,
603 jit_hook: None,
604 }
605 }
606
607 pub fn set_tracer(&mut self, tracer: Box<dyn Tracer + 'a>) {
608 self.tracer = tracer;
609 }
610
611 /// Install (or replace) the JIT hook consulted by `Op::Call`'s
612 /// dispatch arm. With `None`, dispatch behaves exactly as before
613 /// — the hook check is a single null-option branch the optimizer
614 /// can hoist. See the [`crate::jit_hook`] module for the
615 /// contract callers must uphold.
616 pub fn set_jit_hook(&mut self, hook: Option<Box<dyn crate::jit_hook::JitHook + 'a>>) {
617 self.jit_hook = hook;
618 }
619
620 /// Cap the number of opcode dispatches before the VM aborts with
621 /// `step limit exceeded`. Useful as a runtime DoS guard against
622 /// untrusted code (e.g. the `agent-tool` sandbox, where an LLM
623 /// could emit `list.fold(list.range(0, 1_000_000_000), …)` to hang
624 /// the host). Default is 10_000_000.
625 pub fn set_step_limit(&mut self, limit: u64) {
626 self.step_limit = limit;
627 }
628
629 pub fn call(&mut self, name: &str, args: Vec<Value>) -> Result<Value, VmError> {
630 let fn_id = self.program.lookup(name).ok_or_else(|| VmError::Panic(format!("no function `{name}`")))?;
631 self.invoke(fn_id, args)
632 }
633
634 /// Vm-level handler for `parser.run` (#221). Routed here from
635 /// `Op::EffectCall` rather than through the `EffectHandler` so
636 /// the recursive parser interpreter has reentrant Vm access for
637 /// closure invocation. Returns the wrapped `Result[T, ParseErr]`
638 /// value the language sees.
639 fn run_parser_op(&mut self, args: Vec<Value>) -> Result<Value, String> {
640 let parser = args.first().cloned()
641 .ok_or_else(|| "parser.run: missing parser arg".to_string())?;
642 let input = match args.get(1) {
643 Some(Value::Str(s)) => s.clone(),
644 _ => return Err("parser.run: input must be Str".into()),
645 };
646 match crate::parser_runtime::run_parser(&parser, &input, 0, self) {
647 Ok((value, _pos)) => Ok(Value::Variant {
648 name: "Ok".into(),
649 args: vec![value],
650 }),
651 Err((pos, msg)) => {
652 let mut e: IndexMap<String, Value> = IndexMap::new();
653 e.insert("pos".into(), Value::Int(pos as i64));
654 e.insert("message".into(), Value::Str(msg.into()));
655 Ok(Value::Variant {
656 name: "Err".into(),
657 args: vec![Value::record_dynamic(e)],
658 })
659 }
660 }
661 }
662
663 // ---- Variant helpers used by conc.* registry ops (#444) ----
664 // Local helpers (avoid pulling in serde / public API). Lex's
665 // `Result`/`Option` are stdlib unions; their runtime shape is a
666 // `Value::Variant { name, args }` with the constructor name as
667 // declared (`Ok`/`Err`/`Some`/`None`).
668
669 /// VM-level handler for `conc.*` effect ops (#381).
670 ///
671 /// * `conc.spawn(init, handler)` — creates an `Actor` wrapping the
672 /// initial state and the handler closure. No background thread is
673 /// started; the actor runs synchronously on the calling thread
674 /// under a `Mutex` so concurrent callers serialise.
675 ///
676 /// * `conc.ask(actor, msg)` — locks the actor, calls
677 /// `handler(state, msg)` on *this* VM (reentrant), expects a
678 /// 2-tuple `(new_state, reply)`, updates the actor's state, and
679 /// returns `reply`.
680 ///
681 /// * `conc.tell(actor, msg)` — same as `ask` but discards the
682 /// reply and returns `Unit`.
683 fn run_conc_op(&mut self, op: &str, args: Vec<Value>) -> Result<Value, String> {
684 match op {
685 "spawn" => {
686 let mut it = args.into_iter();
687 let init = it.next().unwrap_or(Value::Unit);
688 let handler = it.next().unwrap_or(Value::Unit);
689 if !matches!(handler, Value::Closure { .. }) {
690 return Err(format!(
691 "conc.spawn: handler must be a Closure, got {handler:?}"));
692 }
693 Ok(Value::Actor(Arc::new(Mutex::new(ActorCell {
694 state: init,
695 handler: crate::value::ActorHandler::Lex(handler),
696 }))))
697 }
698 "ask" | "tell" => {
699 let mut it = args.into_iter();
700 let actor_val = it.next().unwrap_or(Value::Unit);
701 let msg = it.next().unwrap_or(Value::Unit);
702 let cell = match actor_val {
703 Value::Actor(ref arc) => Arc::clone(arc),
704 other => return Err(format!(
705 "conc.{op}: first arg must be an Actor, got {other:?}")),
706 };
707 // Lock the actor: guarantees at-most-one-concurrent message.
708 let mut guard = cell.lock().map_err(|e| format!("conc.{op}: actor mutex poisoned: {e}"))?;
709 let handler = guard.handler.clone();
710 let state = guard.state.clone();
711 match handler {
712 crate::value::ActorHandler::Lex(closure_val) => {
713 // Call handler(state, msg) on this VM — full effect access.
714 let result = self.invoke_closure_value(closure_val, vec![state, msg])
715 .map_err(|e| format!("conc.{op}: handler error: {e:?}"))?;
716 // #698: when `ask`/`tell` runs inside a `net.serve` worker, an
717 // arena request-scope is active, so the handler's `(new_state,
718 // reply)` tuple is allocated as a `Value::ArenaTuple` rather than
719 // a heap `Value::Tuple` — and the bare match below would reject it.
720 // Materialize arena handles into heap-owned form NOW, while the
721 // producing scope is still active: the reply crosses back to the
722 // caller and `new_state` persists in the actor cell beyond this
723 // request's arena scope, so both must be heap-owned. Idempotent
724 // (a no-op walk) when there are no arena handles, e.g. from `main`.
725 let result = self.materialize_arena_handles(result);
726 // Expect (new_state, reply) tuple.
727 match result {
728 Value::Tuple(mut parts) if parts.len() == 2 => {
729 let reply = parts.pop().unwrap();
730 let new_state = parts.pop().unwrap();
731 guard.state = new_state;
732 drop(guard);
733 if op == "ask" { Ok(reply) } else { Ok(Value::Unit) }
734 }
735 other => Err(format!(
736 "conc.{op}: handler must return a 2-tuple (new_state, reply), got {other:?}")),
737 }
738 }
739 crate::value::ActorHandler::Native(native) => {
740 // Native bridge: fire-and-forget; `state` is unused
741 // (the bridge's "state" is the external resource, e.g.
742 // a WebSocket connection). The closure receives `msg`
743 // directly. `ask` returns whatever the bridge produces;
744 // `tell` discards it. State stays untouched.
745 drop(guard);
746 let result = (native.send)(msg)
747 .map_err(|e| format!("conc.{op}: native handler error: {e}"))?;
748 if op == "ask" { Ok(result) } else { Ok(Value::Unit) }
749 }
750 }
751 }
752 "register" => {
753 // conc.register(actor, name) -> Result[Unit, ConcError]
754 // Returns Ok(Unit) on first register, Err(AlreadyRegistered(name))
755 // if the name is taken. v1 stores the actor opaquely —
756 // see crate::conc_registry for the type-tag note.
757 let mut it = args.into_iter();
758 let actor = it.next().unwrap_or(Value::Unit);
759 if !matches!(actor, Value::Actor(_)) {
760 return Err(format!(
761 "conc.register: first arg must be an Actor, got {actor:?}"));
762 }
763 let name = match it.next() {
764 Some(Value::Str(s)) => s.to_string(),
765 other => return Err(format!(
766 "conc.register: name must be Str, got {other:?}")),
767 };
768 Ok(match crate::conc_registry::register(&name, actor) {
769 Ok(()) => variant_ok(Value::Unit),
770 Err(crate::conc_registry::RegError::AlreadyRegistered(n)) => {
771 variant_err(variant("AlreadyRegistered", vec![Value::Str(n.into())]))
772 }
773 Err(crate::conc_registry::RegError::NotRegistered(_)) => {
774 unreachable!("register cannot produce NotRegistered")
775 }
776 })
777 }
778 "lookup" => {
779 // conc.lookup(name) -> Option[Actor[S, M]]
780 // Returns Some(actor) if registered, None otherwise. The
781 // [S, M] static parametrisation at the call site is not
782 // checked at runtime in v1 — caller's responsibility to
783 // match the registration site's type.
784 let mut it = args.into_iter();
785 let name = match it.next() {
786 Some(Value::Str(s)) => s.to_string(),
787 other => return Err(format!(
788 "conc.lookup: name must be Str, got {other:?}")),
789 };
790 Ok(match crate::conc_registry::lookup(&name) {
791 Some(actor) => variant("Some", vec![actor]),
792 None => variant("None", vec![]),
793 })
794 }
795 "unregister" => {
796 // conc.unregister(name) -> Result[Unit, ConcError]
797 let mut it = args.into_iter();
798 let name = match it.next() {
799 Some(Value::Str(s)) => s.to_string(),
800 other => return Err(format!(
801 "conc.unregister: name must be Str, got {other:?}")),
802 };
803 Ok(match crate::conc_registry::unregister(&name) {
804 Ok(()) => variant_ok(Value::Unit),
805 Err(crate::conc_registry::RegError::NotRegistered(n)) => {
806 variant_err(variant("NotRegistered", vec![Value::Str(n.into())]))
807 }
808 Err(crate::conc_registry::RegError::AlreadyRegistered(_)) => {
809 unreachable!("unregister cannot produce AlreadyRegistered")
810 }
811 })
812 }
813 "registered" => {
814 // conc.registered() -> List[Str] — sorted snapshot.
815 let names = crate::conc_registry::registered();
816 Ok(Value::List(names.into_iter()
817 .map(|n| Value::Str(n.into()))
818 .collect()))
819 }
820 other => Err(format!("unknown conc.{other}")),
821 }
822 }
823
824 /// Open a request-scoped arena via the underlying
825 /// `EffectHandler::enter_request_scope` (#463 scaffolding).
826 /// Runtime layers — `net.serve_fn`, `net.serve_ws`,
827 /// `net.serve_quic` — call this immediately before invoking the
828 /// user handler closure for a single request. Pair with
829 /// `exit_request_scope` once the response has been built and
830 /// any lazy iterators in it have been drained (#477).
831 ///
832 /// Returns the scope id the runtime should pass back to
833 /// `exit_request_scope`. The handler's default impl returns 0
834 /// and the matching `exit` is a no-op; `DefaultHandler`'s
835 /// implementation actually allocates an arena.
836 pub fn enter_request_scope(&mut self) -> u64 {
837 // #463 slice 2a: snapshot the slab high-water mark so
838 // `exit_request_scope` can truncate back to here, releasing
839 // every arena-allocated value the scope built in O(1).
840 self.arena_scope_starts.push(self.arena_slab.len() as u32);
841 self.handler.enter_request_scope()
842 }
843
844 /// True iff there is at least one active request scope — i.e. an
845 /// `enter_request_scope` not yet matched by `exit_request_scope`.
846 /// Runtime layers use this to skip `materialize_arena_handles` on
847 /// paths where no scope was entered (e.g. tiny-http worker
848 /// dispatch), keeping the no-arena path zero-cost. Slice 2b-i.
849 pub fn arena_scope_active(&self) -> bool {
850 !self.arena_scope_starts.is_empty()
851 }
852
853 /// Close the request scope opened by `enter_request_scope`.
854 /// Drops the associated arena.
855 pub fn exit_request_scope(&mut self, scope_id: u64) {
856 // #463 slice 2a: truncate the slab back to the matching
857 // `enter` snapshot, then notify the handler. Out-of-order /
858 // unpaired exits (e.g. a stray `exit` with no prior `enter`)
859 // are tolerated as no-ops — the handler does the same, and a
860 // stray exit shouldn't crash a live server.
861 if let Some(start) = self.arena_scope_starts.pop() {
862 self.arena_slab.truncate(start as usize);
863 }
864 self.handler.exit_request_scope(scope_id)
865 }
866
867 /// Deep-walk `value` and resolve every `Value::ArenaRecord` /
868 /// `Value::ArenaTuple` handle into its heap-owned equivalent
869 /// (`Value::Record` / `Value::Tuple`), reading field contents
870 /// out of `Vm::arena_slab` along the way. Primitives, closures,
871 /// maps/sets, and the host-managed handles (`Actor` / `Ticker` /
872 /// `ArrowTable`) are returned unchanged.
873 ///
874 /// **The boundary helper** flagged in
875 /// `docs/design/arena-plumbing.md` § "Arena handles MUST be
876 /// readable at serialization". Callers — the response
877 /// serialization path in `lex-runtime`, the trace recorder when
878 /// it records a Call/EffectCall arg, anywhere a value crosses
879 /// out of the VM into host-managed storage — call this
880 /// **while the producing scope is still active**, before
881 /// `exit_request_scope`. After exit the slab is truncated, so a
882 /// handle materialized after-the-fact would read garbage (or
883 /// panic on the bounds check).
884 ///
885 /// `Value::StackRecord` / `Value::StackTuple` would similarly
886 /// need slab resolution, but the #464 escape analysis prevents
887 /// them from reaching boundary-crossing ops in the first place
888 /// (they're frame-local by construction). Reaching here means a
889 /// hand-built or analysis-buggy program; we panic with the same
890 /// loud-not-silent contract the other inspection paths use.
891 ///
892 /// Idempotent on already-materialized values (no arena handles
893 /// in the tree → only the recursive walk's clones, no slab
894 /// lookups). Cost per call is one walk + clone of the tree —
895 /// amortized over the per-node mallocs avoided during request
896 /// handling, the net stays strongly positive.
897 pub fn materialize_arena_handles(&self, value: Value) -> Value {
898 use crate::value::Value as V;
899 match value {
900 // Primitives + opaque handles cross unchanged. Cheap
901 // — clones are essentially free for the Copy-ish ones
902 // and Arc-bumps for the handle types.
903 V::Int(_) | V::Float(_) | V::Bool(_) | V::Str(_) | V::Bytes(_)
904 | V::Unit | V::Closure { .. } | V::F64Array { .. }
905 | V::Map(_) | V::Set(_) | V::Actor(_) | V::Ticker(_)
906 | V::ArrowTable(_) => value,
907
908 // Containers: recurse on each element. Map/Set keys are
909 // MapKey (Str | Int), never Value, so no handles can
910 // hide there.
911 V::List(items) => V::List(
912 items.into_iter().map(|v| self.materialize_arena_handles(v)).collect()),
913 V::Tuple(items) => V::Tuple(
914 items.into_iter().map(|v| self.materialize_arena_handles(v)).collect()),
915 V::Deque(items) => V::Deque(
916 items.into_iter().map(|v| self.materialize_arena_handles(v)).collect()),
917 V::Variant { name, args } => V::Variant {
918 name,
919 args: args.into_iter().map(|v| self.materialize_arena_handles(v)).collect(),
920 },
921 V::Record { shape_id, fields } => {
922 let mut out: IndexMap<SmolStr, Value> = IndexMap::with_capacity(fields.len());
923 for (k, v) in fields.into_iter() {
924 out.insert(k, self.materialize_arena_handles(v));
925 }
926 V::Record { shape_id, fields: Box::new(out) }
927 }
928
929 // The actual resolution work — read the slab and build a
930 // heap form. Field-name ordering for ArenaRecord matches
931 // the shape's, same as `MakeRecord`'s IndexMap insertion
932 // pattern; that's the contract that makes the polymorphic
933 // GetField IC work, and we reuse it here.
934 V::ArenaRecord { shape_id, slab_start, field_count } => {
935 let start = slab_start as usize;
936 let n = field_count as usize;
937 debug_assert!(start + n <= self.arena_slab.len(),
938 "ArenaRecord handle out of bounds — likely materialized after exit_request_scope");
939 let shape = &self.program.record_shapes[shape_id as usize];
940 let mut fields: IndexMap<SmolStr, Value> = IndexMap::with_capacity(n);
941 for (i, name_const_idx) in shape.iter().take(n).enumerate() {
942 let name: SmolStr = match &self.program.constants[*name_const_idx as usize] {
943 Const::FieldName(s) => s.as_str().into(),
944 _ => panic!("BUG(#463): ArenaRecord shape entry not a FieldName const"),
945 };
946 let v = self.materialize_arena_handles(self.arena_slab[start + i].clone());
947 fields.insert(name, v);
948 }
949 V::Record { shape_id, fields: Box::new(fields) }
950 }
951 V::ArenaTuple { slab_start, arity } => {
952 let start = slab_start as usize;
953 let n = arity as usize;
954 debug_assert!(start + n <= self.arena_slab.len(),
955 "ArenaTuple handle out of bounds — likely materialized after exit_request_scope");
956 let items: Vec<Value> = (0..n)
957 .map(|i| self.materialize_arena_handles(self.arena_slab[start + i].clone()))
958 .collect();
959 V::Tuple(items)
960 }
961
962 // #464 stack handles are frame-local; the analysis
963 // prevents them from reaching any boundary the
964 // materializer is called at. Reach = bug; panic loud.
965 V::StackRecord { .. } =>
966 panic!("BUG(#464/#463): Value::StackRecord reached materialize_arena_handles \
967 — escape analysis should keep stack handles inside their frame"),
968 V::StackTuple { .. } =>
969 panic!("BUG(#464/#463): Value::StackTuple reached materialize_arena_handles \
970 — escape analysis should keep stack handles inside their frame"),
971 }
972 }
973
974 /// Read a named field out of a record without materializing its
975 /// parent. Works uniformly on `Value::Record` (heap) and
976 /// `Value::ArenaRecord` (slab handle), so a runtime layer can
977 /// consume the response record structurally — straight out of
978 /// the arena slab — instead of paying for a tree-wide
979 /// `materialize_arena_handles` walk just to read three top-level
980 /// fields.
981 ///
982 /// Returns `None` if the value isn't a record or the field
983 /// doesn't exist. The returned `Value` is a clone of the slot
984 /// contents (records' field values can themselves be records,
985 /// variants, etc.; cloning at the boundary is unavoidable
986 /// without lifetime trickery on the public API).
987 ///
988 /// Performance: on the heap path it's a `IndexMap::get` + clone.
989 /// On the arena path it's a linear walk of the shape's
990 /// field-name vec (`field_count` long, typically ≤ 10) +
991 /// an O(1) slab index + clone. The polymorphic-IC equivalent
992 /// inside the VM is faster, but this API is for **host**
993 /// consumers, not hot-loop dispatch.
994 ///
995 /// `Value::StackRecord` is deliberately not handled — those
996 /// handles are frame-local by construction (#464 escape pass)
997 /// and shouldn't reach host boundaries; reaching them here is
998 /// a soundness bug surfaced as a panic, matching the existing
999 /// inspection-path contract.
1000 pub fn get_record_field(&self, value: &Value, name: &str) -> Option<Value> {
1001 match value {
1002 Value::Record { fields, .. } => fields.get(name).cloned(),
1003 Value::ArenaRecord { shape_id, slab_start, field_count } => {
1004 let shape = self.program.record_shapes.get(*shape_id as usize)?;
1005 let n = (*field_count as usize).min(shape.len());
1006 for (i, &name_const_idx) in shape.iter().take(n).enumerate() {
1007 if let Const::FieldName(s) = &self.program.constants[name_const_idx as usize] {
1008 if s == name {
1009 return Some(self.arena_slab[*slab_start as usize + i].clone());
1010 }
1011 }
1012 }
1013 None
1014 }
1015 Value::StackRecord { .. } =>
1016 panic!("BUG(#464): Value::StackRecord reached Vm::get_record_field \
1017 — frame-local handles should never reach the host boundary"),
1018 _ => None,
1019 }
1020 }
1021
1022 /// Positional read out of a tuple without materializing its
1023 /// parent. Works uniformly on `Value::Tuple` and
1024 /// `Value::ArenaTuple`. See `get_record_field` for the lifetime
1025 /// rationale.
1026 pub fn get_tuple_elem(&self, value: &Value, idx: u16) -> Option<Value> {
1027 match value {
1028 Value::Tuple(items) => items.get(idx as usize).cloned(),
1029 Value::ArenaTuple { slab_start, arity } => {
1030 if idx >= *arity { return None; }
1031 Some(self.arena_slab[*slab_start as usize + idx as usize].clone())
1032 }
1033 Value::StackTuple { .. } =>
1034 panic!("BUG(#464): Value::StackTuple reached Vm::get_tuple_elem \
1035 — frame-local handles should never reach the host boundary"),
1036 _ => None,
1037 }
1038 }
1039
1040 /// Arena-aware `to_json` — produces a `serde_json::Value` from
1041 /// a `Value` whose tree may contain `ArenaRecord` / `ArenaTuple`
1042 /// handles, reading them straight out of `Vm::arena_slab`
1043 /// instead of materializing into a heap `Value::Record` mirror
1044 /// first.
1045 ///
1046 /// Equivalent output to `value.to_json()` on a fully-materialized
1047 /// tree (idempotent in that sense). Use this when serializing a
1048 /// handler return value to JSON for the response — saves the
1049 /// per-node IndexMap allocations the materialize-then-to_json
1050 /// pattern pays.
1051 pub fn value_to_json(&self, value: &Value) -> serde_json::Value {
1052 use serde_json::Value as J;
1053 match value {
1054 // Primitives + opaque host handles: delegate to the
1055 // existing `Value::to_json` — its output is identical
1056 // and it handles the host-handle types we don't model
1057 // (Actor / Ticker / ArrowTable / F64Array / Map / Set /
1058 // Closure / Bytes encoding) in one place.
1059 Value::Int(_) | Value::Float(_) | Value::Bool(_) | Value::Str(_)
1060 | Value::Bytes(_) | Value::Unit | Value::Closure { .. }
1061 | Value::F64Array { .. } | Value::Map(_) | Value::Set(_)
1062 | Value::Actor(_) | Value::Ticker(_) | Value::ArrowTable(_)
1063 => value.to_json(),
1064
1065 Value::List(items) => J::Array(items.iter().map(|v| self.value_to_json(v)).collect()),
1066 Value::Tuple(items) => J::Array(items.iter().map(|v| self.value_to_json(v)).collect()),
1067 Value::Deque(items) => J::Array(items.iter().map(|v| self.value_to_json(v)).collect()),
1068 Value::Variant { name, args } => {
1069 let mut m = serde_json::Map::new();
1070 m.insert("$variant".into(), J::String(name.clone()));
1071 m.insert("args".into(),
1072 J::Array(args.iter().map(|v| self.value_to_json(v)).collect()));
1073 J::Object(m)
1074 }
1075 Value::Record { fields, .. } => {
1076 let mut m = serde_json::Map::new();
1077 for (k, v) in fields.iter() {
1078 m.insert(k.to_string(), self.value_to_json(v));
1079 }
1080 J::Object(m)
1081 }
1082
1083 // Slab-direct: read the cells in shape order, emit a
1084 // JSON object using the shape's field names. The cost
1085 // delta vs the `Value::to_json` materialize-then-walk
1086 // path is the saved `Box<IndexMap>` allocation +
1087 // insertion + drop.
1088 Value::ArenaRecord { shape_id, slab_start, field_count } => {
1089 let shape = match self.program.record_shapes.get(*shape_id as usize) {
1090 Some(s) => s,
1091 None => return J::Null,
1092 };
1093 let n = (*field_count as usize).min(shape.len());
1094 let mut m = serde_json::Map::with_capacity(n);
1095 for (i, &name_const_idx) in shape.iter().take(n).enumerate() {
1096 let name = match &self.program.constants[name_const_idx as usize] {
1097 Const::FieldName(s) => s.to_string(),
1098 _ => continue,
1099 };
1100 let cell = &self.arena_slab[*slab_start as usize + i];
1101 m.insert(name, self.value_to_json(cell));
1102 }
1103 J::Object(m)
1104 }
1105 Value::ArenaTuple { slab_start, arity } => {
1106 let start = *slab_start as usize;
1107 let n = *arity as usize;
1108 let items: Vec<serde_json::Value> = (0..n)
1109 .map(|i| self.value_to_json(&self.arena_slab[start + i]))
1110 .collect();
1111 J::Array(items)
1112 }
1113
1114 // Stack handles must not reach the host — same defensive
1115 // panic as the other inspection paths.
1116 Value::StackRecord { .. } =>
1117 panic!("BUG(#464): Value::StackRecord reached Vm::value_to_json \
1118 — frame-local handles should never reach the host boundary"),
1119 Value::StackTuple { .. } =>
1120 panic!("BUG(#464): Value::StackTuple reached Vm::value_to_json \
1121 — frame-local handles should never reach the host boundary"),
1122 }
1123 }
1124
1125 pub fn invoke(&mut self, fn_id: u32, args: Vec<Value>) -> Result<Value, VmError> {
1126 let f = &self.program.functions[fn_id as usize];
1127 if args.len() != f.arity as usize {
1128 return Err(VmError::Panic(format!("arity mismatch calling {}", f.name)));
1129 }
1130 // Refinement runtime check at the public entry point too
1131 // (#209 slice 3). `Op::Call` checks for in-program calls;
1132 // this branch covers `vm.call("entry", ...)` from the host
1133 // and the reentrant `invoke_closure_value` path. Same
1134 // semantics, same error shape.
1135 //
1136 // Iterate `f.refinements` by reference — the loop body
1137 // only reads from `self.program` (via `r`) and from locals,
1138 // so we don't need to clone the Vec to detach it from
1139 // `&self`. The function name is cloned **lazily**, only on
1140 // the failure path: functions with no refinements (the common
1141 // case) never enter the loop, so the per-call `f.name.clone()`
1142 // was pure waste on the hot path (#464 call-overhead).
1143 for (i, refinement) in f.refinements.iter().enumerate() {
1144 if let Some(r) = refinement {
1145 let arg = args.get(i).cloned().unwrap_or(Value::Unit);
1146 match eval_refinement(&r.predicate, &r.binding, &arg) {
1147 Ok(true) => {}
1148 Ok(false) => return Err(VmError::RefinementFailed {
1149 fn_name: f.name.clone(),
1150 param_index: i,
1151 binding: r.binding.clone(),
1152 reason: format!("predicate failed for {} = {arg:?}", r.binding),
1153 }),
1154 Err(reason) => return Err(VmError::RefinementFailed {
1155 fn_name: f.name.clone(),
1156 param_index: i,
1157 binding: r.binding.clone(),
1158 reason,
1159 }),
1160 }
1161 }
1162 }
1163 // #465 JIT tier hook at the public entry — same contract as
1164 // the `Op::Call` dispatch arm. Pure-fn memo is not consulted
1165 // at this layer (memo is per-Op::Call); the hook fires
1166 // unconditionally for refinement-clean calls. Pass the step
1167 // counter + limit so JITed loops can account against the
1168 // VM's DoS guard (architectural fix; see jit_hook.rs).
1169 if let Some(mut hook) = self.jit_hook.take() {
1170 let step_ptr = &mut self.steps as *mut u64;
1171 let limit = self.step_limit;
1172 let hook_result = hook.try_call(fn_id, &args, step_ptr, limit);
1173 self.jit_hook = Some(hook);
1174 if let Some(result) = hook_result? {
1175 return Ok(result);
1176 }
1177 }
1178 let f = &self.program.functions[fn_id as usize];
1179 // Claim slots from the locals stack allocator (#389 slice 3).
1180 let locals_start = self.locals_storage.len();
1181 let locals_len = f.locals_count.max(f.arity) as usize;
1182 self.locals_storage.resize(locals_start + locals_len, Value::Unit);
1183 for (i, v) in args.into_iter().enumerate() {
1184 self.locals_storage[locals_start + i] = v;
1185 }
1186 // Record the depth before pushing — this is what `run` will
1187 // exit at, supporting reentrant invocation from inside the
1188 // VM (e.g. the parser interpreter calling closures, #221).
1189 let base_depth = self.frames.len();
1190 self.push_frame(Frame {
1191 fn_id, pc: 0, locals_start, locals_len,
1192 stack_base: self.stack.len(),
1193 trace_kind: FrameKind::Entry,
1194 memo_key: None,
1195 stack_record_arena_start: self.stack_record_arena.len(),
1196 stack_record_budget_remaining: STACK_RECORD_BUDGET_SLOTS,
1197 })?;
1198 self.run_to(base_depth)
1199 }
1200
1201 /// All call-frame pushes funnel through here so the depth
1202 /// check can't be skipped by a missing branch. Returns
1203 /// `CallStackOverflow` instead of letting recursion blow the
1204 /// host's native stack.
1205 fn push_frame(&mut self, frame: Frame) -> Result<(), VmError> {
1206 if self.frames.len() as u32 >= MAX_CALL_DEPTH {
1207 return Err(VmError::CallStackOverflow(MAX_CALL_DEPTH));
1208 }
1209 self.frames.push(frame);
1210 Ok(())
1211 }
1212
1213}
1214
1215impl Drop for Vm<'_> {
1216 fn drop(&mut self) {
1217 if ic_stats_enabled() {
1218 dump_ic_stats();
1219 }
1220 }
1221}
1222
1223/// Construct a `Value::Variant` with the given name and args.
1224/// Used by `conc.*` registry ops to return `Result`/`Option`/`ConcError`
1225/// values without hand-writing the struct literal at every site.
1226fn variant(name: &str, args: Vec<Value>) -> Value {
1227 Value::Variant { name: name.to_string(), args }
1228}
1229fn variant_ok(payload: Value) -> Value { variant("Ok", vec![payload]) }
1230fn variant_err(payload: Value) -> Value { variant("Err", vec![payload]) }
1231
1232fn const_to_value(c: &Const) -> Value {
1233 match c {
1234 Const::Int(n) => Value::Int(*n),
1235 Const::Float(f) => Value::Float(*f),
1236 Const::Bool(b) => Value::Bool(*b),
1237 Const::Str(s) => Value::Str(s.as_str().into()),
1238 Const::Bytes(b) => Value::Bytes(b.clone()),
1239 Const::Unit => Value::Unit,
1240 Const::FieldName(s) | Const::VariantName(s) | Const::NodeId(s) => Value::Str(s.as_str().into()),
1241 }
1242}