Skip to main content

harn_vm/vm/
modules.rs

1use std::collections::{BTreeMap, HashSet};
2use std::future::Future;
3use std::hash::{Hash, Hasher};
4use std::path::{Path, PathBuf};
5use std::pin::Pin;
6use std::sync::{Arc, Mutex, OnceLock};
7
8use crate::bytecode_cache;
9use crate::chunk::{Chunk, CompiledFunction};
10use crate::module_artifact::{compile_module_artifact_from_source, ModuleArtifact};
11use crate::value::{ModuleFunctionRegistry, VmClosure, VmEnv, VmError, VmValue};
12
13use super::{ScopeSpan, Vm};
14
15static STDLIB_MODULE_ARTIFACT_CACHE: OnceLock<Mutex<BTreeMap<String, Arc<ModuleArtifact>>>> =
16    OnceLock::new();
17
18fn stdlib_module_artifact_cache() -> &'static Mutex<BTreeMap<String, Arc<ModuleArtifact>>> {
19    STDLIB_MODULE_ARTIFACT_CACHE.get_or_init(|| Mutex::new(BTreeMap::new()))
20}
21
22#[cfg(test)]
23fn reset_stdlib_module_artifact_cache() {
24    stdlib_module_artifact_cache().lock().unwrap().clear();
25}
26
27#[cfg(test)]
28fn stdlib_module_artifact_cache_ptr(module: &str, source: &str) -> Option<usize> {
29    let key = stdlib_artifact_cache_key(module, source);
30    stdlib_module_artifact_cache()
31        .lock()
32        .unwrap()
33        .get(&key)
34        .map(|artifact| Arc::as_ptr(artifact) as usize)
35}
36
37#[derive(Clone)]
38pub(crate) struct LoadedModule {
39    pub(crate) functions: BTreeMap<String, Arc<VmClosure>>,
40    pub(crate) public_names: HashSet<String>,
41    /// Evaluated values of exported `pub const` / `pub let` bindings, read out
42    /// of the instantiated module env after the init chunk ran. Importers bind
43    /// these by value (like every other cross-module value). Disjoint from
44    /// `functions` and `public_type_names`.
45    pub(crate) public_values: BTreeMap<String, VmValue>,
46    /// Names of `pub type` aliases (and re-exported ones). Erased at runtime:
47    /// selective imports may name them, but they bind no value of their own.
48    pub(crate) public_type_names: HashSet<String>,
49    /// Decoded JSON-Schema dict for each `pub type` alias that lowers to a
50    /// schema. Importers bind the alias name to this value so
51    /// expression-position uses (`output_schema: ImportedAlias`) work.
52    pub(crate) public_type_schemas: BTreeMap<String, VmValue>,
53    pub(crate) _module_functions: crate::value::ModuleFunctionRegistry,
54    pub(crate) _module_state: crate::value::ModuleState,
55}
56
57/// An import whose target module was still mid-load (an import cycle) when the
58/// importing module reached it. The target's function closures don't exist yet
59/// at that point, so the binding can't happen inline. We record it here and
60/// resolve it once both modules are fully loaded — see
61/// [`Vm::flush_deferred_cyclic_imports`].
62#[derive(Clone, Debug)]
63pub(crate) struct DeferredCyclicImport {
64    /// Canonical path of the module that issued the import.
65    pub(crate) importer: PathBuf,
66    /// Canonical path of the cyclically-imported target module.
67    pub(crate) target: PathBuf,
68    /// Selectively-imported names, or `None` for a wildcard/side-effect import.
69    pub(crate) selected_names: Option<Vec<String>>,
70}
71
72pub fn resolve_module_import_path(base: &Path, path: &str) -> PathBuf {
73    let synthetic_current_file = base.join("__harn_import_base__.harn");
74    if let Some(resolved) = harn_modules::resolve_import_path(&synthetic_current_file, path) {
75        return resolved;
76    }
77
78    let mut file_path = base.join(path);
79
80    if !file_path.exists() && file_path.extension().is_none() {
81        file_path.set_extension("harn");
82    }
83
84    file_path
85}
86
87fn stdlib_artifact_cache_key(module: &str, source: &str) -> String {
88    let mut hasher = std::collections::hash_map::DefaultHasher::new();
89    module.hash(&mut hasher);
90    source.hash(&mut hasher);
91    format!("{module}:{:016x}", hasher.finish())
92}
93
94fn stdlib_module_artifact(
95    module: &str,
96    synthetic: &Path,
97    source: &'static str,
98) -> Result<Arc<ModuleArtifact>, VmError> {
99    let key = stdlib_artifact_cache_key(module, source);
100    {
101        let cache = stdlib_module_artifact_cache().lock().unwrap();
102        if let Some(cached) = cache.get(&key) {
103            return Ok(Arc::clone(cached));
104        }
105    }
106
107    // Stdlib modules are embedded in the binary so their content cannot
108    // legitimately change between processes; that means the disk cache
109    // for stdlib can use a synthetic source_path. The harn_version field
110    // of the cache key gates correctness across releases.
111    let lookup = bytecode_cache::load_module(synthetic, source);
112    let artifact = if let Some(artifact) = lookup.artifact {
113        artifact
114    } else {
115        let compiled = compile_module_artifact_from_source(synthetic, source)?;
116        if let Err(err) = bytecode_cache::store_module(&lookup.key, &compiled) {
117            if std::env::var_os("HARN_BYTECODE_CACHE_DEBUG").is_some() {
118                eprintln!("[harn] stdlib module cache write skipped for {module}: {err}");
119            }
120        }
121        compiled
122    };
123
124    let compiled = Arc::new(artifact);
125    let mut cache = stdlib_module_artifact_cache().lock().unwrap();
126    if let Some(cached) = cache.get(&key) {
127        return Ok(Arc::clone(cached));
128    }
129    cache.insert(key, Arc::clone(&compiled));
130    Ok(compiled)
131}
132
133impl Vm {
134    /// Resolve a callable against this VM. Lazy callables initialize once per
135    /// VM execution tree, then retain that module scope for later child VMs in
136    /// the same tree. Fresh VM roots remain isolated.
137    pub async fn resolve_callable(
138        &mut self,
139        callable: &crate::value::VmCallable,
140    ) -> Result<Arc<crate::value::VmClosure>, VmError> {
141        self.ensure_execution_available()?;
142        match callable {
143            crate::value::VmCallable::Eager(closure) => Ok(Arc::clone(closure)),
144            crate::value::VmCallable::Lazy(lazy) => {
145                let (cache_key, module_path) = self.lazy_callable_module_path(lazy);
146                let resolution = {
147                    let mut modules = self.lazy_callable_modules.lock();
148                    Arc::clone(
149                        modules
150                            .entry(cache_key)
151                            .or_insert_with(|| Arc::new(tokio::sync::OnceCell::new())),
152                    )
153                };
154                let resolved = resolution
155                    .get_or_try_init(|| async {
156                        let exports = self.load_module_exports(&module_path).await?;
157                        let exports = exports
158                            .into_iter()
159                            .map(|(name, closure)| (name, closure.retained_for_host_registry()))
160                            .collect();
161                        // Pin the complete module graph loaded above so that a
162                        // handler's transitively imported callees keep their
163                        // home-module registries/state alive for later child
164                        // VMs that hit this cache without re-importing.
165                        Ok::<_, VmError>(Arc::new(crate::vm::state::ResolvedLazyCallable {
166                            exports,
167                            retained_module_graph: Arc::clone(&self.module_cache),
168                        }))
169                    })
170                    .await?;
171                resolved
172                    .exports
173                    .get(&lazy.function_name)
174                    .cloned()
175                    .ok_or_else(|| {
176                        VmError::Runtime(format!(
177                            "function '{}' is not exported by module '{}'",
178                            lazy.function_name,
179                            lazy.module_path.display()
180                        ))
181                    })
182            }
183        }
184    }
185
186    fn lazy_callable_module_path(&self, lazy: &crate::value::LazyVmCallable) -> (PathBuf, PathBuf) {
187        let mut module_path = if lazy.module_path.is_absolute() {
188            lazy.module_path.clone()
189        } else {
190            self.source_dir
191                .clone()
192                .unwrap_or_else(|| PathBuf::from("."))
193                .join(&lazy.module_path)
194        };
195        if !module_path.exists() && module_path.extension().is_none() {
196            module_path.set_extension("harn");
197        }
198        let cache_key = module_path
199            .canonicalize()
200            .unwrap_or_else(|_| module_path.clone());
201        (cache_key, module_path)
202    }
203
204    async fn load_module_from_source(
205        &mut self,
206        synthetic: PathBuf,
207        source: &str,
208    ) -> Result<LoadedModule, VmError> {
209        if let Some(loaded) = self.module_cache.get(&synthetic).cloned() {
210            return Ok(loaded);
211        }
212        Arc::make_mut(&mut self.source_cache).insert(synthetic.clone(), source.to_string());
213
214        let artifact = compile_module_artifact_from_source(&synthetic, source)?;
215
216        self.imported_paths.push(synthetic.clone());
217        let loaded = self.instantiate_module(None, &artifact).await?;
218        self.imported_paths.pop();
219        Arc::make_mut(&mut self.module_cache).insert(synthetic, loaded.clone());
220        Ok(loaded)
221    }
222
223    async fn load_stdlib_module_from_source(
224        &mut self,
225        module: &str,
226        synthetic: PathBuf,
227        source: &'static str,
228    ) -> Result<LoadedModule, VmError> {
229        if let Some(loaded) = self.module_cache.get(&synthetic).cloned() {
230            return Ok(loaded);
231        }
232        Arc::make_mut(&mut self.source_cache).insert(synthetic.clone(), source.to_string());
233
234        let artifact = stdlib_module_artifact(module, &synthetic, source)?;
235        self.imported_paths.push(synthetic.clone());
236        let loaded = self.instantiate_stdlib_module(artifact.as_ref()).await?;
237        self.imported_paths.pop();
238        Arc::make_mut(&mut self.module_cache).insert(synthetic, loaded.clone());
239        Ok(loaded)
240    }
241
242    async fn instantiate_stdlib_module(
243        &mut self,
244        artifact: &ModuleArtifact,
245    ) -> Result<LoadedModule, VmError> {
246        self.instantiate_module(None, artifact).await
247    }
248
249    /// Instantiate a previously-compiled [`ModuleArtifact`] into a
250    /// [`LoadedModule`]. Re-runs nested imports, replays the init chunk
251    /// into a fresh module env, mints a [`VmClosure`] for each compiled
252    /// function (stamped with `module_source_dir` so imports from inside
253    /// those functions resolve against the originating file), and
254    /// applies the re-export pass. Used by both stdlib and user-import
255    /// code paths.
256    async fn instantiate_module(
257        &mut self,
258        module_source_dir: Option<PathBuf>,
259        artifact: &ModuleArtifact,
260    ) -> Result<LoadedModule, VmError> {
261        let caller_env = self.env.clone();
262        let old_source_dir = self.source_dir.clone();
263        self.env = VmEnv::new();
264        self.source_dir = module_source_dir.clone();
265
266        for import in &artifact.imports {
267            self.execute_import(&import.path, import.selected_names.as_deref())
268                .await?;
269        }
270
271        let module_state: crate::value::ModuleState = {
272            let mut init_env = self.env.clone();
273            if let Some(init_chunk) = &artifact.init_chunk {
274                let fresh_init_chunk = Chunk::from_cached(init_chunk);
275                let saved_env = std::mem::replace(&mut self.env, init_env);
276                let saved_frames = std::mem::take(&mut self.frames);
277                let saved_handlers = std::mem::take(&mut self.exception_handlers);
278                let saved_iterators = std::mem::take(&mut self.iterators);
279                let saved_deadlines = std::mem::take(&mut self.deadlines);
280                // STEP_STACK / PERSONA_STACK are thread-locals shared with
281                // the calling frame. Emptying `self.frames` above means
282                // any `prune_below_frame(0)` triggered while the init
283                // chunk's bytecode runs — including the inevitable
284                // frame-pop prune at end-of-chunk — would wipe active
285                // steps owned by the *caller* (e.g., a `@step`-decorated
286                // function whose body lazily imports a module). Snapshot
287                // the persona/step context here and restore it after init
288                // so module loading is invisible to the step-tracking
289                // surface.
290                let active_context = crate::step_runtime::take_active_context();
291                let init_result = self.run_chunk(std::sync::Arc::new(fresh_init_chunk)).await;
292                crate::step_runtime::restore_active_context(active_context);
293                init_env = std::mem::replace(&mut self.env, saved_env);
294                self.frames = saved_frames;
295                self.exception_handlers = saved_handlers;
296                self.iterators = saved_iterators;
297                self.deadlines = saved_deadlines;
298                init_result?;
299            }
300            Arc::new(crate::value::VmMutex::new(init_env))
301        };
302
303        let module_env = self.env.clone();
304        let registry: ModuleFunctionRegistry =
305            Arc::new(crate::value::VmMutex::new(BTreeMap::new()));
306        let mut functions: BTreeMap<String, Arc<VmClosure>> = BTreeMap::new();
307        let mut public_names = artifact.public_names.clone();
308        // `pub const` / `pub let`: the init chunk already ran into `module_state`,
309        // so the bound values are live there. Read each exported value name out
310        // and publish it so importers can bind it. Add the names to
311        // `public_names` too, so the selective/wildcard export machinery treats
312        // them as part of the public surface (validation, re-export lists).
313        let mut public_values: BTreeMap<String, VmValue> = BTreeMap::new();
314        {
315            let state = module_state.lock();
316            for name in &artifact.public_value_names {
317                if let Some(value) = state.get(name) {
318                    public_values.insert(name.clone(), value);
319                    public_names.insert(name.clone());
320                }
321            }
322        }
323        let mut public_type_names = artifact.public_type_names.clone();
324        let mut public_type_schemas: BTreeMap<String, VmValue> = artifact
325            .public_type_schemas
326            .iter()
327            .filter_map(|(name, json)| {
328                let parsed = serde_json::from_str::<serde_json::Value>(json).ok()?;
329                Some((name.clone(), crate::schema::json_to_vm_value(&parsed)))
330            })
331            .collect();
332
333        for (name, compiled) in &artifact.functions {
334            let closure = Arc::new(VmClosure {
335                func: Arc::new(CompiledFunction::from_cached(compiled)),
336                env: module_env.clone(),
337                source_dir: module_source_dir.clone(),
338                module_functions: Some(Arc::downgrade(&registry)),
339                module_state: Some(Arc::downgrade(&module_state)),
340                retained_module_scope: None,
341            });
342            registry.lock().insert(name.clone(), Arc::clone(&closure));
343            self.env
344                .define(name, VmValue::Closure(Arc::clone(&closure)), false)?;
345            module_state
346                .lock()
347                .define(name, VmValue::Closure(Arc::clone(&closure)), false)?;
348            functions.insert(name.clone(), Arc::clone(&closure));
349        }
350
351        for import in artifact.imports.iter().filter(|import| import.is_pub) {
352            let cache_key = self.cache_key_for_import(&import.path);
353            let Some(loaded) = self.module_cache.get(&cache_key).cloned() else {
354                // A plain `import`/`import {...}` across a cycle is bound late
355                // by `flush_deferred_cyclic_imports`, but a `pub import`
356                // re-export has to publish the names into *this* module's
357                // public surface right now — and the target is still mid-load,
358                // so its surface does not exist yet. Name the cycle explicitly
359                // instead of the misleading "was not loaded".
360                if self.imported_paths.contains(&cache_key) {
361                    return Err(VmError::Runtime(format!(
362                        "Re-export error: cannot `pub import` from '{}' because it forms an \
363                         import cycle with this module (its public surface is still being \
364                         built). Use a plain `import` here, or re-export from a module that is \
365                         not part of the cycle.",
366                        import.path
367                    )));
368                }
369                return Err(VmError::Runtime(format!(
370                    "Re-export error: imported module '{}' was not loaded",
371                    import.path
372                )));
373            };
374            let names_to_reexport: Vec<String> = match &import.selected_names {
375                Some(names) => names.clone(),
376                // A wildcard `pub import` re-exports exactly the target's `pub`
377                // surface (functions and erased `pub type` aliases). A module
378                // with no `pub` declarations exports nothing.
379                None => loaded
380                    .public_names
381                    .iter()
382                    .chain(loaded.public_type_names.iter())
383                    .cloned()
384                    .collect(),
385            };
386            for name in names_to_reexport {
387                let Some(closure) = loaded.functions.get(&name) else {
388                    // `pub const` / `pub let` values carry no closure: re-export
389                    // the value directly.
390                    if let Some(value) = loaded.public_values.get(&name) {
391                        public_values.insert(name.clone(), value.clone());
392                        public_names.insert(name);
393                        continue;
394                    }
395                    // `pub type` aliases are erased at runtime: re-export the
396                    // name (and its schema lowering, when present) for
397                    // importers, with no closure to bind.
398                    if loaded.public_type_names.contains(&name) {
399                        if let Some(schema) = loaded.public_type_schemas.get(&name) {
400                            public_type_schemas.insert(name.clone(), schema.clone());
401                        }
402                        public_type_names.insert(name);
403                        continue;
404                    }
405                    return Err(VmError::Runtime(format!(
406                        "Re-export error: '{name}' is not exported by '{}'",
407                        import.path
408                    )));
409                };
410                if let Some(existing) = functions.get(&name) {
411                    if !Arc::ptr_eq(existing, closure) {
412                        return Err(VmError::Runtime(format!(
413                            "Re-export collision: '{name}' is defined here and also \
414                             re-exported from '{}'",
415                            import.path
416                        )));
417                    }
418                }
419                functions.insert(name.clone(), Arc::clone(closure));
420                public_names.insert(name);
421            }
422        }
423
424        self.env = caller_env;
425        self.source_dir = old_source_dir;
426
427        Ok(LoadedModule {
428            functions,
429            public_names,
430            public_values,
431            public_type_names,
432            public_type_schemas,
433            _module_functions: registry,
434            _module_state: module_state,
435        })
436    }
437
438    fn export_loaded_module(
439        &mut self,
440        module_path: &Path,
441        loaded: &LoadedModule,
442        selected_names: Option<&[String]>,
443    ) -> Result<(), VmError> {
444        let module_name = module_path.display().to_string();
445        let export_names: Vec<String> = if let Some(names) = selected_names {
446            // Selective imports may only name symbols the module marks `pub`.
447            // A module with no `pub` functions exports nothing — matching every
448            // strict-visibility language (TypeScript, Rust, Go) and removing
449            // the old footgun where adding the first `pub` silently turned every
450            // other (previously importable) function private to callers.
451            for name in names {
452                if !loaded.public_names.contains(name) && !loaded.public_type_names.contains(name) {
453                    let hint = if loaded.functions.contains_key(name) {
454                        " — it is defined there but not `pub`; mark it `pub` to export it"
455                    } else {
456                        ""
457                    };
458                    return Err(VmError::Runtime(format!(
459                        "Import error: '{name}' is not exported by {module_name}{hint}"
460                    )));
461                }
462            }
463            names.to_vec()
464        } else {
465            // Wildcard import brings in exactly the module's `pub` surface,
466            // including erased `pub type` aliases.
467            loaded
468                .public_names
469                .iter()
470                .chain(loaded.public_type_names.iter())
471                .cloned()
472                .collect()
473        };
474
475        for name in export_names {
476            // `pub type` aliases are erased at runtime: the import is valid
477            // (the type checker consumed it). When the alias lowers to a JSON
478            // schema, bind the name to that dict so expression-position uses
479            // (`output_schema: ImportedAlias`, `schema_is(x, ImportedAlias)`)
480            // behave like a locally declared alias; otherwise bind nothing.
481            if loaded.public_type_names.contains(&name) && !loaded.functions.contains_key(&name) {
482                if let Some(schema) = loaded.public_type_schemas.get(&name) {
483                    self.env.define(&name, schema.clone(), false)?;
484                }
485                continue;
486            }
487            // `pub const` / `pub let` values: bind by value.
488            if let Some(value) = loaded.public_values.get(&name) {
489                if self.env.get(&name).is_some() {
490                    return Err(VmError::Runtime(format!(
491                        "Import collision: '{name}' is already defined when importing \
492                         {module_name}. Use selective imports to disambiguate: \
493                         import {{ {name} }} from \"...\""
494                    )));
495                }
496                self.env.define(&name, value.clone(), false)?;
497                continue;
498            }
499            let Some(closure) = loaded.functions.get(&name) else {
500                return Err(VmError::Runtime(format!(
501                    "Import error: '{name}' is not defined in {module_name}"
502                )));
503            };
504            if let Some(VmValue::Closure(_)) = self.env.get(&name) {
505                return Err(VmError::Runtime(format!(
506                    "Import collision: '{name}' is already defined when importing {module_name}. \
507                     Use selective imports to disambiguate: import {{ {name} }} from \"...\""
508                )));
509            }
510            self.env
511                .define(&name, VmValue::Closure(Arc::clone(closure)), false)?;
512        }
513        Ok(())
514    }
515
516    /// Execute an import, reading and running the file's declarations.
517    pub(super) fn execute_import<'a>(
518        &'a mut self,
519        path: &'a str,
520        selected_names: Option<&'a [String]>,
521    ) -> Pin<Box<dyn Future<Output = Result<(), VmError>> + Send + 'a>> {
522        Box::pin(async move {
523            let _import_span = ScopeSpan::new(crate::tracing::SpanKind::Import, path.to_string());
524
525            let stdlib_module = path
526                .strip_prefix("std/")
527                .or_else(|| (path == "observability").then_some("observability"));
528            if let Some(module) = stdlib_module {
529                if let Some(source) = crate::stdlib_modules::get_stdlib_source(module) {
530                    let synthetic = PathBuf::from(format!("<stdlib>/{module}.harn"));
531                    if self.imported_paths.contains(&synthetic) {
532                        return Ok(());
533                    }
534                    let loaded = self
535                        .load_stdlib_module_from_source(module, synthetic.clone(), source)
536                        .await?;
537                    self.export_loaded_module(&synthetic, &loaded, selected_names)?;
538                    return Ok(());
539                }
540                return Err(VmError::Runtime(format!(
541                    "Unknown stdlib module: std/{module}"
542                )));
543            }
544
545            let base = self
546                .source_dir
547                .clone()
548                .unwrap_or_else(|| PathBuf::from("."));
549            let file_path = resolve_module_import_path(&base, path);
550
551            let canonical = file_path
552                .canonicalize()
553                .unwrap_or_else(|_| file_path.clone());
554            if self.imported_paths.contains(&canonical) {
555                // Import cycle: `canonical` is still mid-load (it sits on the
556                // import stack), so its function closures don't exist yet and
557                // we cannot bind the requested names inline. Record the import
558                // and resolve it once both modules finish loading — otherwise
559                // whichever module happens to close the cycle silently loses
560                // these bindings and fails with `Undefined builtin` at call
561                // time, in a load-order-dependent way.
562                if let Some(importer) = self.imported_paths.last().cloned() {
563                    if importer != canonical {
564                        self.deferred_cyclic_imports.push(DeferredCyclicImport {
565                            importer,
566                            target: canonical.clone(),
567                            selected_names: selected_names.map(<[String]>::to_vec),
568                        });
569                    }
570                }
571                return Ok(());
572            }
573            if let Some(loaded) = self.module_cache.get(&canonical).cloned() {
574                return self.export_loaded_module(&canonical, &loaded, selected_names);
575            }
576            self.imported_paths.push(canonical.clone());
577
578            let source = std::fs::read_to_string(&file_path).map_err(|e| {
579                // Name the resolution base: relative imports resolve against the
580                // importing file's dir (or CWD when unset), so an error that
581                // prints only the joined path leaves the author guessing which
582                // base was used.
583                VmError::Runtime(format!(
584                    "Import error: cannot read '{}' (resolved '{path}' relative to {}): {e}",
585                    file_path.display(),
586                    base.display()
587                ))
588            })?;
589            Arc::make_mut(&mut self.source_cache).insert(canonical.clone(), source.clone());
590            Arc::make_mut(&mut self.source_cache).insert(file_path.clone(), source.clone());
591
592            // Disk cache first: hits skip parse + compile for the imported
593            // module's whole function pool, not just the entry pipeline.
594            let lookup = bytecode_cache::load_module(&file_path, &source);
595            let artifact = if let Some(artifact) = lookup.artifact {
596                artifact
597            } else {
598                let compiled = compile_module_artifact_from_source(&file_path, &source)?;
599                if let Err(err) = bytecode_cache::store_module(&lookup.key, &compiled) {
600                    if std::env::var_os("HARN_BYTECODE_CACHE_DEBUG").is_some() {
601                        eprintln!(
602                            "[harn] module cache write skipped for {}: {err}",
603                            file_path.display()
604                        );
605                    }
606                }
607                compiled
608            };
609
610            let module_source_dir = file_path.parent().map(|p| p.to_path_buf());
611            let loaded = self
612                .instantiate_module(module_source_dir, &artifact)
613                .await?;
614            self.imported_paths.pop();
615            Arc::make_mut(&mut self.module_cache).insert(canonical.clone(), loaded.clone());
616            self.export_loaded_module(&canonical, &loaded, selected_names)?;
617
618            // Once the import stack fully unwinds, every module reachable from
619            // this top-level import is cached, so any deferred cyclic imports
620            // can now bind against fully-loaded modules.
621            if self.imported_paths.is_empty() {
622                self.flush_deferred_cyclic_imports()?;
623            }
624
625            Ok(())
626        })
627    }
628
629    /// Bind imports that were deferred because their target module was still
630    /// mid-load (an import cycle). By the time the import stack has unwound,
631    /// both the importing and target modules are fully instantiated and cached,
632    /// so we can resolve the requested names against the target and define them
633    /// into the importer's shared, mutable `module_state`. That env is the one
634    /// every closure from the importing module consults (after its local env)
635    /// at call time, so the late binding becomes visible without needing to
636    /// rewrite the closures' captured lexical snapshots.
637    fn flush_deferred_cyclic_imports(&mut self) -> Result<(), VmError> {
638        if self.deferred_cyclic_imports.is_empty() {
639            return Ok(());
640        }
641        let deferred = std::mem::take(&mut self.deferred_cyclic_imports);
642        let mut still_pending = Vec::new();
643        for import in deferred {
644            let (Some(importer), Some(target)) = (
645                self.module_cache.get(&import.importer).cloned(),
646                self.module_cache.get(&import.target).cloned(),
647            ) else {
648                // One endpoint is not cached yet (a lazy import inside a
649                // function body can defer before the other side loads). Keep
650                // it for a later flush.
651                still_pending.push(import);
652                continue;
653            };
654
655            let export_names: Vec<String> = match &import.selected_names {
656                Some(names) => names.clone(),
657                None if !target.public_names.is_empty() => {
658                    target.public_names.iter().cloned().collect()
659                }
660                None => target.functions.keys().cloned().collect(),
661            };
662
663            let mut module_state = importer._module_state.lock();
664            for name in export_names {
665                // A real local declaration (or an already-bound non-cyclic
666                // import) wins over the cyclic re-binding.
667                if module_state.get(&name).is_some() {
668                    continue;
669                }
670                if let Some(closure) = target.functions.get(&name) {
671                    module_state.define(&name, VmValue::Closure(Arc::clone(closure)), false)?;
672                } else if let Some(value) = target.public_values.get(&name) {
673                    // `pub const` / `pub let` imported across a cycle.
674                    module_state.define(&name, value.clone(), false)?;
675                } else {
676                    return Err(VmError::Runtime(format!(
677                        "Import error: '{name}' is not defined in {}",
678                        import.target.display()
679                    )));
680                }
681            }
682        }
683        self.deferred_cyclic_imports = still_pending;
684        Ok(())
685    }
686
687    /// Return the path key that `execute_import` would use to cache the
688    /// LoadedModule for this import string. Used by the re-export pass to
689    /// look up the already-loaded source module after `execute_import`
690    /// has populated [`Vm::module_cache`].
691    fn cache_key_for_import(&self, path: &str) -> PathBuf {
692        if let Some(module) = path
693            .strip_prefix("std/")
694            .or_else(|| (path == "observability").then_some("observability"))
695        {
696            return PathBuf::from(format!("<stdlib>/{module}.harn"));
697        }
698        let base = self
699            .source_dir
700            .clone()
701            .unwrap_or_else(|| PathBuf::from("."));
702        let file_path = resolve_module_import_path(&base, path);
703        file_path.canonicalize().unwrap_or(file_path)
704    }
705
706    /// Load a module file and return the exported function closures that
707    /// would be visible to a wildcard import.
708    pub async fn load_module_exports(
709        &mut self,
710        path: &Path,
711    ) -> Result<BTreeMap<String, Arc<VmClosure>>, VmError> {
712        self.ensure_execution_available()?;
713        let path_str = path.to_string_lossy().into_owned();
714        self.execute_import(&path_str, None).await?;
715
716        let mut file_path = if path.is_absolute() {
717            path.to_path_buf()
718        } else {
719            self.source_dir
720                .clone()
721                .unwrap_or_else(|| PathBuf::from("."))
722                .join(path)
723        };
724        if !file_path.exists() && file_path.extension().is_none() {
725            file_path.set_extension("harn");
726        }
727
728        let canonical = file_path
729            .canonicalize()
730            .unwrap_or_else(|_| file_path.clone());
731        let loaded = self.module_cache.get(&canonical).cloned().ok_or_else(|| {
732            VmError::Runtime(format!(
733                "Import error: failed to cache loaded module '{}'",
734                canonical.display()
735            ))
736        })?;
737
738        let export_names: Vec<String> = if loaded.public_names.is_empty() {
739            loaded.functions.keys().cloned().collect()
740        } else {
741            loaded.public_names.iter().cloned().collect()
742        };
743
744        let mut exports = BTreeMap::new();
745        for name in export_names {
746            let Some(closure) = loaded.functions.get(&name) else {
747                return Err(VmError::Runtime(format!(
748                    "Import error: exported function '{name}' is missing from {}",
749                    canonical.display()
750                )));
751            };
752            exports.insert(name, Arc::clone(closure));
753        }
754
755        Ok(exports)
756    }
757
758    /// Load synthetic source keyed by a synthetic module path and return
759    /// the exported function closures that a wildcard import would expose.
760    pub async fn load_module_exports_from_source(
761        &mut self,
762        source_key: impl Into<PathBuf>,
763        source: &str,
764    ) -> Result<BTreeMap<String, Arc<VmClosure>>, VmError> {
765        self.ensure_execution_available()?;
766        let synthetic = source_key.into();
767        let loaded = self
768            .load_module_from_source(synthetic.clone(), source)
769            .await?;
770        let export_names: Vec<String> = if loaded.public_names.is_empty() {
771            loaded.functions.keys().cloned().collect()
772        } else {
773            loaded.public_names.iter().cloned().collect()
774        };
775
776        let mut exports = BTreeMap::new();
777        for name in export_names {
778            let Some(closure) = loaded.functions.get(&name) else {
779                return Err(VmError::Runtime(format!(
780                    "Import error: exported function '{name}' is missing from {}",
781                    synthetic.display()
782                )));
783            };
784            exports.insert(name, Arc::clone(closure));
785        }
786
787        Ok(exports)
788    }
789
790    /// Load a module by import path (`std/foo`, relative module path, or
791    /// package import) and return the exported function closures that a
792    /// wildcard import would expose.
793    pub async fn load_module_exports_from_import(
794        &mut self,
795        import_path: &str,
796    ) -> Result<BTreeMap<String, Arc<VmClosure>>, VmError> {
797        self.ensure_execution_available()?;
798        self.execute_import(import_path, None).await?;
799
800        if let Some(module) = import_path
801            .strip_prefix("std/")
802            .or_else(|| (import_path == "observability").then_some("observability"))
803        {
804            let synthetic = PathBuf::from(format!("<stdlib>/{module}.harn"));
805            let loaded = self.module_cache.get(&synthetic).cloned().ok_or_else(|| {
806                VmError::Runtime(format!(
807                    "Import error: failed to cache loaded module '{}'",
808                    synthetic.display()
809                ))
810            })?;
811            let mut exports = BTreeMap::new();
812            let export_names: Vec<String> = if loaded.public_names.is_empty() {
813                loaded.functions.keys().cloned().collect()
814            } else {
815                loaded.public_names.iter().cloned().collect()
816            };
817            for name in export_names {
818                let Some(closure) = loaded.functions.get(&name) else {
819                    return Err(VmError::Runtime(format!(
820                        "Import error: exported function '{name}' is missing from {}",
821                        synthetic.display()
822                    )));
823                };
824                exports.insert(name, Arc::clone(closure));
825            }
826            return Ok(exports);
827        }
828
829        let base = self
830            .source_dir
831            .clone()
832            .unwrap_or_else(|| PathBuf::from("."));
833        let file_path = resolve_module_import_path(&base, import_path);
834        self.load_module_exports(&file_path).await
835    }
836}
837
838#[cfg(test)]
839mod tests {
840
841    use std::sync::{Mutex, MutexGuard, OnceLock};
842
843    use super::*;
844
845    static CACHE_TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
846
847    fn cache_test_guard() -> MutexGuard<'static, ()> {
848        CACHE_TEST_LOCK
849            .get_or_init(|| Mutex::new(()))
850            .lock()
851            .unwrap()
852    }
853
854    fn cached_stdlib_module_ptr(module: &str) -> Option<usize> {
855        let source = harn_stdlib::get_stdlib_source(module).expect("stdlib module source exists");
856        stdlib_module_artifact_cache_ptr(module, source)
857    }
858
859    #[test]
860    fn module_function_can_use_local_type_alias_as_schema_value() {
861        let runtime = tokio::runtime::Builder::new_current_thread()
862            .enable_all()
863            .build()
864            .expect("runtime builds");
865
866        let result = runtime.block_on(async {
867            let mut vm = Vm::new();
868            crate::stdlib::register_vm_stdlib(&mut vm);
869            let loaded = vm
870                .load_module_from_source(
871                    PathBuf::from("<test>/schema_alias_module.harn"),
872                    r#"
873fn accepts_schema(schema) {
874  return schema_report({name: "Ada"}, schema).ok
875}
876
877type UserShape = {name: string}
878
879pub fn works() {
880  return accepts_schema(UserShape)
881}
882"#,
883                )
884                .await
885                .expect("module loads");
886            let closure = Arc::clone(loaded.functions.get("works").expect("works export exists"));
887            vm.call_closure_pub(&closure, &[])
888                .await
889                .expect("module closure executes")
890        });
891
892        assert!(matches!(result, VmValue::Bool(true)), "{result:?}");
893    }
894
895    #[test]
896    fn stdlib_artifact_cache_reuses_compilation_with_fresh_vm_state() {
897        let _guard = cache_test_guard();
898        reset_stdlib_module_artifact_cache();
899        let runtime = tokio::runtime::Builder::new_current_thread()
900            .enable_all()
901            .build()
902            .expect("runtime builds");
903
904        let (first_exports, second_exports, first_state_weak, second_state_weak) = runtime
905            .block_on(async {
906                let mut first_vm = Vm::new();
907                let first_exports = first_vm
908                    .load_module_exports_from_import("std/agent/prompts")
909                    .await
910                    .expect("first stdlib import succeeds");
911                let first_state = first_exports
912                    .get("render_agent_prompt")
913                    .expect("first export exists")
914                    .module_state()
915                    .expect("first module state stays live while VM owns module");
916                let first_state_weak = Arc::downgrade(&first_state);
917                let first_state_ptr = Arc::as_ptr(&first_state);
918
919                let mut second_vm = Vm::new();
920                let second_exports = second_vm
921                    .load_module_exports_from_import("std/agent/prompts")
922                    .await
923                    .expect("second stdlib import succeeds");
924                let second_state = second_exports
925                    .get("render_agent_prompt")
926                    .expect("second export exists")
927                    .module_state()
928                    .expect("second module state stays live while VM owns module");
929                let second_state_weak = Arc::downgrade(&second_state);
930
931                assert_ne!(first_state_ptr, Arc::as_ptr(&second_state));
932                (
933                    first_exports,
934                    second_exports,
935                    first_state_weak,
936                    second_state_weak,
937                )
938            });
939        let first_cached =
940            cached_stdlib_module_ptr("agent/prompts").expect("first import cached stdlib artifact");
941        assert_eq!(
942            cached_stdlib_module_ptr("agent/prompts"),
943            Some(first_cached)
944        );
945
946        let first = first_exports
947            .get("render_agent_prompt")
948            .expect("first export exists");
949        let second = second_exports
950            .get("render_agent_prompt")
951            .expect("second export exists");
952
953        assert!(!Arc::ptr_eq(first, second));
954        assert!(!Arc::ptr_eq(&first.func, &second.func));
955        assert!(!Arc::ptr_eq(&first.func.chunk, &second.func.chunk));
956        assert!(first.module_state().is_none());
957        assert!(second.module_state().is_none());
958        assert!(first_state_weak.upgrade().is_none());
959        assert!(second_state_weak.upgrade().is_none());
960    }
961
962    #[test]
963    fn stdlib_artifact_cache_is_process_wide_across_threads() {
964        let _guard = cache_test_guard();
965        reset_stdlib_module_artifact_cache();
966
967        let handle = std::thread::spawn(|| {
968            let runtime = tokio::runtime::Builder::new_current_thread()
969                .enable_all()
970                .build()
971                .expect("runtime builds");
972            runtime.block_on(async {
973                let mut vm = Vm::new();
974                vm.load_module_exports_from_import("std/agent/prompts")
975                    .await
976                    .expect("thread stdlib import succeeds");
977            });
978        });
979        handle.join().expect("thread joins");
980        let thread_cached = cached_stdlib_module_ptr("agent/prompts")
981            .expect("thread import cached stdlib artifact");
982
983        let runtime = tokio::runtime::Builder::new_current_thread()
984            .enable_all()
985            .build()
986            .expect("runtime builds");
987        runtime.block_on(async {
988            let mut vm = Vm::new();
989            vm.load_module_exports_from_import("std/agent/prompts")
990                .await
991                .expect("main-thread stdlib import succeeds");
992        });
993        assert_eq!(
994            cached_stdlib_module_ptr("agent/prompts"),
995            Some(thread_cached)
996        );
997    }
998
999    #[test]
1000    fn module_closures_release_state_after_vm_drop() {
1001        let runtime = tokio::runtime::Builder::new_current_thread()
1002            .enable_all()
1003            .build()
1004            .expect("runtime builds");
1005
1006        let (closure_weak, registry_weak, state_weak) = runtime.block_on(async {
1007            let mut vm = Vm::new();
1008            let loaded = vm
1009                .load_module_from_source(
1010                    PathBuf::from("<test>/module_cycle.harn"),
1011                    r#"
1012let payload = "x" * 1024
1013
1014pub fn touch() {
1015  return len(payload)
1016}
1017"#,
1018                )
1019                .await
1020                .expect("module loads");
1021            let closure = Arc::clone(loaded.functions.get("touch").expect("touch export exists"));
1022            let closure_weak = Arc::downgrade(&closure);
1023            let registry_weak = Arc::downgrade(&loaded._module_functions);
1024            let state_weak = Arc::downgrade(&loaded._module_state);
1025
1026            drop(closure);
1027            drop(loaded);
1028            drop(vm);
1029
1030            (closure_weak, registry_weak, state_weak)
1031        });
1032
1033        assert!(
1034            closure_weak.upgrade().is_none(),
1035            "module closure should drop with its VM"
1036        );
1037        assert!(
1038            registry_weak.upgrade().is_none(),
1039            "module function registry should drop with its VM"
1040        );
1041        assert!(
1042            state_weak.upgrade().is_none(),
1043            "module state should drop with its VM"
1044        );
1045    }
1046}