harn_vm/value/env.rs
1use std::collections::BTreeMap;
2use std::path::PathBuf;
3use std::sync::{Arc, Weak};
4
5use crate::chunk::CompiledFunctionRef;
6use crate::orchestration::CapabilityPolicy;
7use crate::trust_graph::AutonomyTier;
8
9use super::{VmError, VmMutex, VmValue};
10
11/// A compiled closure value.
12#[derive(Debug, Clone)]
13pub struct VmClosure {
14 pub func: CompiledFunctionRef,
15 pub env: VmEnv,
16 /// Source directory for this closure's originating module.
17 /// When set, `render()` and other source-relative builtins resolve
18 /// paths relative to this directory instead of the entry pipeline.
19 pub source_dir: Option<PathBuf>,
20 /// Module-local named functions that should resolve before builtin fallback.
21 /// This lets selectively imported functions keep private sibling helpers
22 /// without exporting them into the caller's environment.
23 pub module_functions: Option<WeakModuleFunctionRegistry>,
24 /// Shared, mutable module-level env: holds top-level `let` / `const`
25 /// bindings declared at the module root (caches, counters, lazily
26 /// initialized registries). All closures created from the same
27 /// module import point at the same shared mutable env, so a
28 /// mutation inside one function is visible to every other function
29 /// in that module on subsequent calls. `closure.env` still holds
30 /// the closure's own lexical bindings (captured function args from
31 /// enclosing scopes, etc.), with captured locals shared by reference
32 /// through [`BindingCell`], and is unchanged by this — `module_state`
33 /// is a separate lookup layer consulted after the local env and
34 /// before globals. Created in `import_declarations` after the
35 /// module's init chunk runs, so the initial values from `let x = ...`
36 /// land in it.
37 pub module_state: Option<WeakModuleState>,
38 /// Strong owners of this closure's module scope, pinned only when the
39 /// closure is stored in a process/thread-local registry that outlives the
40 /// VM that created it (reminder providers, session/lifecycle hooks). See
41 /// [`RetainedModuleScope`] and [`VmClosure::retained_for_host_registry`].
42 /// `None` for the overwhelmingly common short-lived closure, whose module
43 /// scope stays alive through the live VM's `module_cache`.
44 pub retained_module_scope: Option<Arc<RetainedModuleScope>>,
45}
46
47/// A VM function that is either already resolved or can be resolved from a
48/// module export against the VM that will invoke it.
49#[derive(Clone, Debug)]
50pub enum VmCallable {
51 Eager(Arc<VmClosure>),
52 Lazy(LazyVmCallable),
53 Pipeline(LazyPipelineCallable),
54}
55
56impl VmCallable {
57 pub fn effective_autonomy_tier(&self, requested: AutonomyTier) -> AutonomyTier {
58 match self {
59 Self::Pipeline(callable) => callable
60 .autonomy_ceiling()
61 .map_or(requested, |ceiling| requested.min(ceiling)),
62 Self::Eager(_) | Self::Lazy(_) => requested,
63 }
64 }
65}
66
67/// Module/export coordinates for a callable whose import graph should not be
68/// instantiated until it is actually invoked.
69#[derive(Clone, Debug, PartialEq, Eq)]
70pub struct LazyVmCallable {
71 pub(crate) module_path: PathBuf,
72 pub(crate) function_name: String,
73 package_execution_guard: Option<Arc<harn_modules::package_execution::PackageExecutionGuard>>,
74}
75
76impl LazyVmCallable {
77 pub fn new(module_path: PathBuf, function_name: impl Into<String>) -> Self {
78 Self {
79 module_path,
80 function_name: function_name.into(),
81 package_execution_guard: None,
82 }
83 }
84
85 pub fn with_package_execution_guard(
86 mut self,
87 guard: Arc<harn_modules::package_execution::PackageExecutionGuard>,
88 ) -> Self {
89 self.package_execution_guard = Some(guard);
90 self
91 }
92
93 pub fn package_execution_guard_handle(
94 &self,
95 ) -> Option<Arc<harn_modules::package_execution::PackageExecutionGuard>> {
96 self.package_execution_guard.clone()
97 }
98}
99
100/// Module/pipeline coordinates for a pipeline entry compiled on invocation.
101#[derive(Clone, Debug, PartialEq, Eq)]
102pub struct LazyPipelineCallable {
103 pub(crate) module_path: PathBuf,
104 pub(crate) pipeline_name: String,
105 execution_policy: Option<Box<CapabilityPolicy>>,
106 autonomy_ceiling: Option<AutonomyTier>,
107 package_execution_guard: Option<Arc<harn_modules::package_execution::PackageExecutionGuard>>,
108}
109
110impl LazyPipelineCallable {
111 pub fn new(module_path: PathBuf, pipeline_name: impl Into<String>) -> Self {
112 Self {
113 module_path,
114 pipeline_name: pipeline_name.into(),
115 execution_policy: None,
116 autonomy_ceiling: None,
117 package_execution_guard: None,
118 }
119 }
120
121 pub fn with_execution_policy(mut self, policy: CapabilityPolicy) -> Self {
122 self.execution_policy = Some(Box::new(policy));
123 self
124 }
125
126 pub fn with_autonomy_ceiling(mut self, ceiling: AutonomyTier) -> Self {
127 self.autonomy_ceiling = Some(ceiling);
128 self
129 }
130
131 pub fn with_package_execution_guard(
132 mut self,
133 guard: Arc<harn_modules::package_execution::PackageExecutionGuard>,
134 ) -> Self {
135 self.package_execution_guard = Some(guard);
136 self
137 }
138
139 pub fn execution_policy(&self) -> Option<&CapabilityPolicy> {
140 self.execution_policy.as_deref()
141 }
142
143 pub fn autonomy_ceiling(&self) -> Option<AutonomyTier> {
144 self.autonomy_ceiling
145 }
146
147 pub fn package_execution_guard_handle(
148 &self,
149 ) -> Option<Arc<harn_modules::package_execution::PackageExecutionGuard>> {
150 self.package_execution_guard.clone()
151 }
152}
153
154pub type ModuleFunctionRegistry = Arc<VmMutex<BTreeMap<String, Arc<VmClosure>>>>;
155pub type WeakModuleFunctionRegistry = Weak<VmMutex<BTreeMap<String, Arc<VmClosure>>>>;
156pub type ModuleState = Arc<VmMutex<VmEnv>>;
157pub type WeakModuleState = Weak<VmMutex<VmEnv>>;
158
159/// Strong owners of a closure's module function table and module-level state.
160///
161/// A [`VmClosure`] resolves sibling module `pub fn`s through its module's
162/// function registry, which it references only via a [`Weak`]
163/// ([`VmClosure::module_functions`] / [`module_state`](VmClosure::module_state)).
164/// The sole strong owner of that registry is normally the registering VM's
165/// `module_cache`. When a closure is registered into a process/thread-local
166/// registry (reminder providers, session/lifecycle hooks) it outlives that VM;
167/// once the VM tears down, the `Weak` dangles and a sibling-fn call inside the
168/// invoked closure falls through name resolution to host-bridge dispatch. This
169/// pins strong owners so the `Weak` stays upgradeable for the closure's whole
170/// retained lifetime.
171///
172/// The fields are intentionally unread — their sole purpose is to keep the
173/// referenced `Arc`s alive.
174#[derive(Debug)]
175pub struct RetainedModuleScope {
176 _functions: Option<ModuleFunctionRegistry>,
177 _state: Option<ModuleState>,
178}
179
180impl VmClosure {
181 pub(crate) fn module_functions(&self) -> Option<ModuleFunctionRegistry> {
182 self.module_functions
183 .as_ref()
184 .and_then(WeakModuleFunctionRegistry::upgrade)
185 }
186
187 pub(crate) fn module_state(&self) -> Option<ModuleState> {
188 self.module_state
189 .as_ref()
190 .and_then(WeakModuleState::upgrade)
191 }
192
193 /// Return a clone of this closure suitable for storage in a process- or
194 /// thread-local registry that outlives the VM that created it (reminder
195 /// providers, session/lifecycle hooks). The clone pins strong owners of
196 /// this closure's module function table and module-level state
197 /// ([`RetainedModuleScope`]), so its body still resolves sibling module
198 /// `pub fn`s after the registering VM — the only other strong owner, via
199 /// `module_cache` — is dropped.
200 ///
201 /// The owners are pinned on a *clone* (a fresh `Arc<VmClosure>` that is
202 /// never itself a member of any function registry), so retaining a closure
203 /// that IS a module `pub fn` cannot form an `Arc` cycle with its registry.
204 ///
205 /// A no-op refcount bump when there is nothing to pin: the closure is
206 /// already pinned, or its `Weak`s do not upgrade — e.g. an entry-chunk
207 /// closure whose sibling functions live in captured `env` rather than a
208 /// module registry, which resolves without this.
209 pub(crate) fn retained_for_host_registry(self: &Arc<Self>) -> Arc<Self> {
210 if self.retained_module_scope.is_some() {
211 return Arc::clone(self);
212 }
213 let functions = self.module_functions();
214 let state = self.module_state();
215 if functions.is_none() && state.is_none() {
216 return Arc::clone(self);
217 }
218 let mut pinned = (**self).clone();
219 pinned.retained_module_scope = Some(Arc::new(RetainedModuleScope {
220 _functions: functions,
221 _state: state,
222 }));
223 Arc::new(pinned)
224 }
225}
226
227/// VM environment for variable storage.
228///
229/// `Scope::vars` is wrapped in `Arc` so that `VmEnv::clone()` is cheap
230/// (Arc bump per scope) instead of a deep walk of every BTreeMap. The
231/// VM saves and restores `env` snapshots on every function call, and
232/// the call hot path dominates orchestration-heavy workloads. With
233/// `Arc<BTreeMap<..>>`, the per-scope clone collapses to a refcount
234/// bump, and `Arc::make_mut` only does a deep copy when the scope is
235/// still shared with a saved snapshot — which is exactly the case where
236/// the caller would have needed an isolated copy anyway. Reads still go
237/// through the `BTreeMap` directly via `Deref`.
238#[derive(Debug, Clone)]
239pub struct VmEnv {
240 pub(crate) scopes: Vec<Scope>,
241}
242
243/// A shared, mutable cell backing a captured binding.
244///
245/// A local that a nested closure captures is stored behind a `Cell` instead of
246/// inline. Cloning a [`Scope`] (which happens on every call and every closure
247/// mint) refcount-bumps this `Arc`, so the defining frame and every closure
248/// that captured the binding all point at the *same* cell — a write through any
249/// of them is observed by all of them. This is what makes closure capture
250/// **by reference** (JS/Python/Swift semantics) while keeping distinct
251/// variables independent (`let b = a` still copies the value out of `a`'s
252/// cell into `b`'s binding). See `docs/design/closure-reference-capture.md`.
253pub(crate) type BindingCell = Arc<VmMutex<VmValue>>;
254
255/// One name's binding in a [`Scope`].
256///
257/// `Value` is the ordinary, unshared binding — a read clones the value out and
258/// a write replaces it (copy-on-assignment), exactly as before. `Cell` is a
259/// binding captured by a nested closure: the value lives behind a shared
260/// [`BindingCell`] so reads clone the inner value out (value semantics for
261/// reads is preserved) and writes go *through* the cell rather than replacing
262/// the map entry — which also sidesteps the scope-map copy-on-write, so shared
263/// mutation survives the per-call env clone.
264#[derive(Debug, Clone)]
265pub(crate) enum Binding {
266 Value { value: VmValue, mutable: bool },
267 Cell { cell: BindingCell, mutable: bool },
268}
269
270impl Binding {
271 #[inline]
272 pub(crate) fn mutable(&self) -> bool {
273 match self {
274 Binding::Value { mutable, .. } | Binding::Cell { mutable, .. } => *mutable,
275 }
276 }
277
278 /// The current value of this binding, cloned out. Reads never expose the
279 /// cell itself — value semantics for reads is identical for both variants.
280 #[inline]
281 pub(crate) fn read(&self) -> VmValue {
282 match self {
283 Binding::Value { value, .. } => value.clone(),
284 Binding::Cell { cell, .. } => cell.lock().clone(),
285 }
286 }
287
288 /// Ownership-taking accessor for the iterative teardown paths. A `Value`
289 /// yields its inner value directly. A `Cell` yields its inner value only
290 /// when this binding holds the *last* reference to the shared cell; a
291 /// still-shared cell yields `None` and is left for its own `Arc` drop to
292 /// reclaim once the final closure releases it.
293 #[inline]
294 pub(crate) fn into_teardown_value(self) -> Option<VmValue> {
295 match self {
296 Binding::Value { value, .. } => Some(value),
297 Binding::Cell { cell, .. } => Arc::into_inner(cell).map(VmMutex::into_inner),
298 }
299 }
300
301 /// Whether this binding *uniquely* owns a deeply-nested container that the
302 /// default recursive drop could overflow the native stack on. A `Value`
303 /// checks its container directly. A `Cell` only qualifies when unshared
304 /// (`strong_count == 1`) — a cell still held by a live closure must not be
305 /// torn down from here — and is peeked with `try_lock` so a drop never
306 /// blocks.
307 #[inline]
308 fn owns_recursive_container(&self) -> bool {
309 match self {
310 Binding::Value { value, .. } => super::recursion::is_recursive_container(value),
311 Binding::Cell { cell, .. } => {
312 Arc::strong_count(cell) == 1
313 && cell
314 .try_lock()
315 .map(|v| super::recursion::is_recursive_container(&v))
316 .unwrap_or(false)
317 }
318 }
319 }
320}
321
322#[derive(Debug, Clone)]
323pub(crate) struct Scope {
324 pub(crate) vars: Arc<BTreeMap<String, Binding>>,
325}
326
327/// Process-wide shared empty binding map.
328///
329/// Every block entry pushes a fresh [`Scope`], but inside a function body its
330/// bindings compile to local slots (`DefLocalSlot`) rather than env writes, so
331/// the pushed scope is overwhelmingly *empty* — a hot loop whose body is a
332/// block would otherwise `Arc::new(BTreeMap::new())`-allocate (and free) one
333/// map per iteration. Sharing a single immutable empty map makes
334/// [`Scope::empty`] a refcount bump instead; the first real `define`/`assign`
335/// copies-on-write away from this shared map via `Arc::make_mut` (the insert
336/// paths already do), so a scope that never binds anything never allocates.
337static EMPTY_SCOPE_VARS: std::sync::LazyLock<Arc<BTreeMap<String, Binding>>> =
338 std::sync::LazyLock::new(|| Arc::new(BTreeMap::new()));
339
340impl Scope {
341 #[inline]
342 fn empty() -> Self {
343 Self {
344 vars: Arc::clone(&EMPTY_SCOPE_VARS),
345 }
346 }
347}
348
349impl Drop for Scope {
350 fn drop(&mut self) {
351 // Deeply nested script values (e.g. `x = [x]` built in a loop, which
352 // adds no VM call frames and so never trips `max_vm_frames`) live in
353 // scope bindings. Their default recursive drop would overflow the
354 // native stack and abort the whole process — an uncatchable failure.
355 // When this scope holds the last reference to its bindings and any
356 // value is a nested container, tear the bindings down iteratively
357 // instead. `Arc::get_mut` succeeds only for a uniquely-owned scope, so
358 // shared snapshots fall through to the cheap default drop and the real
359 // teardown happens later at the last owner (also a `Scope`).
360 //
361 // A still-shared `Cell` may outlive this scope (a live closure holds
362 // it), so its `Arc` refcount — not this map's — governs when its inner
363 // value drops. `into_teardown_value` therefore only reclaims a cell we
364 // uniquely own; shared cells fall through to their own `Arc` drop.
365 if let Some(map) = Arc::get_mut(&mut self.vars) {
366 if map.values().any(Binding::owns_recursive_container) {
367 let bindings = std::mem::take(map);
368 super::recursion::dismantle_values(
369 bindings
370 .into_values()
371 .filter_map(Binding::into_teardown_value),
372 );
373 }
374 }
375 }
376}
377
378impl Default for VmEnv {
379 fn default() -> Self {
380 Self::new()
381 }
382}
383
384impl VmEnv {
385 pub fn new() -> Self {
386 Self {
387 scopes: vec![Scope::empty()],
388 }
389 }
390
391 pub fn push_scope(&mut self) {
392 self.scopes.push(Scope::empty());
393 }
394
395 /// Clone the scope stack for a fresh call frame, reserving room for the
396 /// one empty scope every invocation pushes for the callee's body.
397 ///
398 /// `Vec::clone` allocates at exactly `len` capacity, so the `push_scope`
399 /// that immediately follows on the call hot path would otherwise force a
400 /// reallocation and copy of the whole scope stack. Reserving the extra
401 /// slot up front folds those two allocations into one. When a caller does
402 /// not end up pushing (no path currently does, but it stays correct if one
403 /// is added), the only cost is a single unused `Scope` slot of capacity.
404 pub(crate) fn cloned_for_call(&self) -> VmEnv {
405 let mut scopes = Vec::with_capacity(self.scopes.len() + 1);
406 scopes.extend(self.scopes.iter().cloned());
407 VmEnv { scopes }
408 }
409
410 pub fn pop_scope(&mut self) {
411 if self.scopes.len() > 1 {
412 self.scopes.pop();
413 }
414 }
415
416 pub fn scope_depth(&self) -> usize {
417 self.scopes.len()
418 }
419
420 pub fn truncate_scopes(&mut self, target_depth: usize) {
421 let min_depth = target_depth.max(1);
422 while self.scopes.len() > min_depth {
423 self.scopes.pop();
424 }
425 }
426
427 pub fn get(&self, name: &str) -> Option<VmValue> {
428 for scope in self.scopes.iter().rev() {
429 if let Some(binding) = scope.vars.get(name) {
430 return Some(binding.read());
431 }
432 }
433 None
434 }
435
436 pub(crate) fn contains(&self, name: &str) -> bool {
437 self.scopes
438 .iter()
439 .rev()
440 .any(|scope| scope.vars.contains_key(name))
441 }
442
443 pub fn define(&mut self, name: &str, value: VmValue, mutable: bool) -> Result<(), VmError> {
444 self.define_binding(name, Binding::Value { value, mutable })
445 }
446
447 /// Define `name` as a **captured** binding: a fresh shared cell holding
448 /// `value`. Emitted for a local that a nested closure captures. A closure
449 /// minted after this point clones the enclosing env (refcount-bumping the
450 /// cell), so its reads and writes of `name` flow through the same cell as
451 /// the defining frame. Called once per activation, so each activation gets
452 /// a distinct cell (per-iteration loop captures stay independent).
453 pub(crate) fn define_cell(
454 &mut self,
455 name: &str,
456 value: VmValue,
457 mutable: bool,
458 ) -> Result<(), VmError> {
459 self.define_binding(
460 name,
461 Binding::Cell {
462 cell: Arc::new(VmMutex::new(value)),
463 mutable,
464 },
465 )
466 }
467
468 fn define_binding(&mut self, name: &str, binding: Binding) -> Result<(), VmError> {
469 if let Some(scope) = self.scopes.last_mut() {
470 if let Some(existing) = scope.vars.get(name) {
471 if !existing.mutable() && !binding.mutable() {
472 return Err(VmError::Runtime(format!(
473 "Cannot redeclare immutable variable '{name}' in the same scope (use 'let' for mutable bindings)"
474 )));
475 }
476 }
477 if let Some(Binding::Value { value, .. }) =
478 Arc::make_mut(&mut scope.vars).insert(name.to_string(), binding)
479 {
480 super::recursion::dismantle(value);
481 }
482 }
483 Ok(())
484 }
485
486 pub fn all_variables(&self) -> crate::value::DictMap {
487 let mut vars = crate::value::DictMap::new();
488 for scope in &self.scopes {
489 for (name, binding) in scope.vars.iter() {
490 vars.insert(crate::value::intern_key(name), binding.read());
491 }
492 }
493 vars
494 }
495
496 pub fn assign(&mut self, name: &str, value: VmValue) -> Result<(), VmError> {
497 for scope in self.scopes.iter_mut().rev() {
498 let Some(existing) = scope.vars.get(name) else {
499 continue;
500 };
501 if !existing.mutable() {
502 return Err(VmError::ImmutableAssignment(name.to_string()));
503 }
504 match existing {
505 // Write *through* the shared cell: the entry is not replaced,
506 // so the scope-map copy-on-write is sidestepped and every
507 // holder of this cell (the defining frame, sibling closures)
508 // observes the update.
509 Binding::Cell { cell, .. } => {
510 let previous = std::mem::replace(&mut *cell.lock(), value);
511 super::recursion::dismantle(previous);
512 }
513 Binding::Value { .. } => {
514 // Iterative teardown so overwriting a deeply nested binding
515 // cannot overflow the stack on drop (scalars are a no-op).
516 // The prior binding here is always a `Value` (a name is
517 // either always boxed or never — see the compiler's capture
518 // pre-pass), so only that arm needs draining.
519 if let Some(Binding::Value { value, .. }) = Arc::make_mut(&mut scope.vars)
520 .insert(
521 name.to_string(),
522 Binding::Value {
523 value,
524 mutable: true,
525 },
526 )
527 {
528 super::recursion::dismantle(value);
529 }
530 }
531 }
532 return Ok(());
533 }
534 Err(VmError::UndefinedVariable(name.to_string()))
535 }
536
537 /// Debugger-only variant of `assign` that rebinds the name even if
538 /// the existing binding was declared with `let`. Pipeline authors
539 /// overwhelmingly use `let`, so a strict mutability check would
540 /// make the DAP `setVariable` request useless for "what-if"
541 /// iteration — which is the whole point of the feature. Preserves
542 /// the original mutability flag so the VM's runtime behavior is
543 /// unchanged after the debugger overrides.
544 pub fn assign_debug(&mut self, name: &str, value: VmValue) -> Result<(), VmError> {
545 for scope in self.scopes.iter_mut().rev() {
546 let Some(existing) = scope.vars.get(name) else {
547 continue;
548 };
549 match existing {
550 // Preserve the shared-cell identity so a debugger override of a
551 // captured binding is still observed by the closures holding it.
552 Binding::Cell { cell, .. } => {
553 *cell.lock() = value;
554 }
555 Binding::Value { mutable, .. } => {
556 let mutable = *mutable;
557 Arc::make_mut(&mut scope.vars)
558 .insert(name.to_string(), Binding::Value { value, mutable });
559 }
560 }
561 return Ok(());
562 }
563 Err(VmError::UndefinedVariable(name.to_string()))
564 }
565}
566
567/// Find the closest match from a list of candidates using Levenshtein distance.
568/// Returns `Some(suggestion)` if a candidate is within `max_dist` edits.
569pub fn closest_match<'a>(name: &str, candidates: impl Iterator<Item = &'a str>) -> Option<String> {
570 let max_dist = match name.len() {
571 0..=2 => 1,
572 3..=5 => 2,
573 _ => 3,
574 };
575 candidates
576 .filter(|c| *c != name && !c.starts_with("__"))
577 .map(|c| (c, strsim::levenshtein(name, c)))
578 .filter(|(_, d)| *d <= max_dist)
579 // Prefer smallest distance, then closest length to original, then alphabetical
580 .min_by(|(a, da), (b, db)| {
581 da.cmp(db)
582 .then_with(|| {
583 let a_diff = (a.len() as isize - name.len() as isize).unsigned_abs();
584 let b_diff = (b.len() as isize - name.len() as isize).unsigned_abs();
585 a_diff.cmp(&b_diff)
586 })
587 .then_with(|| a.cmp(b))
588 })
589 .map(|(c, _)| c.to_string())
590}
591
592#[cfg(test)]
593mod scope_alloc_tests {
594 use super::*;
595
596 #[test]
597 fn empty_scopes_share_one_backing_map() {
598 // Pushing block scopes (the per-iteration cost in a loop body) must not
599 // allocate: every empty scope shares the process-wide empty map.
600 let mut env = VmEnv::new();
601 env.push_scope();
602 env.push_scope();
603 for scope in &env.scopes {
604 assert!(Arc::ptr_eq(&scope.vars, &EMPTY_SCOPE_VARS));
605 }
606 }
607
608 #[test]
609 fn define_copies_on_write_without_disturbing_siblings() {
610 let mut env = VmEnv::new();
611 env.push_scope(); // shares EMPTY
612 env.define("x", VmValue::Int(1), true).unwrap();
613 // The bound scope copied on write away from the shared empty map...
614 let top = env.scopes.last().unwrap();
615 assert!(!Arc::ptr_eq(&top.vars, &EMPTY_SCOPE_VARS));
616 // ...while the root scope (untouched) still shares it.
617 assert!(Arc::ptr_eq(&env.scopes[0].vars, &EMPTY_SCOPE_VARS));
618 assert!(matches!(env.get("x"), Some(VmValue::Int(1))));
619 // Popping the scope drops the binding entirely.
620 env.pop_scope();
621 assert!(env.get("x").is_none());
622 }
623}