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