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