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