Skip to main content

harn_vm/
prepared_module.rs

1//! Scoped cache of immutable, hydrated module bytecode.
2//!
3//! Prepared modules deliberately stop before runtime instantiation. Each VM
4//! still receives fresh closures, function registries, module state, and init
5//! execution; only the serialized-to-runtime bytecode conversion is reused.
6
7use std::collections::{BTreeMap, VecDeque};
8use std::num::NonZeroUsize;
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11
12use harn_modules::DefKind;
13use parking_lot::Mutex;
14
15use crate::chunk::{Chunk, CompiledFunction};
16use crate::module_artifact::{
17    compile_module_artifact_from_source, compile_module_artifact_from_source_with_imported_symbols,
18    compile_trusted_host_dispatch_module_artifact_from_source,
19    compile_trusted_host_dispatch_module_artifact_from_source_with_imported_symbols,
20    ModuleArtifact, ModuleImportSpec, ModuleProvenance,
21};
22use crate::module_source::ModuleSource;
23use crate::{ModulePhaseRecorder, ModulePhaseStats, VmError};
24const DEFAULT_MAX_ENTRIES: usize = 512;
25
26/// Immutable runtime form of one compiled module artifact.
27pub(crate) struct PreparedModuleArtifact {
28    pub(crate) provenance: ModuleProvenance,
29    pub(crate) imports: Vec<ModuleImportSpec>,
30    pub(crate) type_schema_init_chunks: Vec<Arc<Chunk>>,
31    pub(crate) init_chunk: Option<Arc<Chunk>>,
32    pub(crate) functions: BTreeMap<String, Arc<CompiledFunction>>,
33    pub(crate) public_exports: BTreeMap<String, DefKind>,
34    pub(crate) public_value_names: std::collections::HashSet<String>,
35    pub(crate) public_type_names: std::collections::HashSet<String>,
36}
37
38impl PreparedModuleArtifact {
39    pub(crate) fn from_cached(artifact: ModuleArtifact) -> Self {
40        let ModuleArtifact {
41            provenance,
42            imports,
43            type_schema_init_chunks,
44            init_chunk,
45            functions,
46            public_exports,
47            public_value_names,
48            public_type_names,
49        } = artifact;
50        let type_schema_init_chunks = type_schema_init_chunks
51            .into_iter()
52            .map(|chunk| Arc::new(Chunk::from_cached(chunk)))
53            .collect();
54        let init_chunk = init_chunk.map(|chunk| Arc::new(Chunk::from_cached(chunk)));
55        let functions = functions
56            .into_iter()
57            .map(|(name, function)| (name, Arc::new(CompiledFunction::from_cached(function))))
58            .collect();
59        Self {
60            provenance,
61            imports,
62            type_schema_init_chunks,
63            init_chunk,
64            functions,
65            public_exports,
66            public_value_names,
67            public_type_names,
68        }
69    }
70}
71
72#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
73struct PreparedModuleCacheKey {
74    canonical_path: PathBuf,
75    source_hash: [u8; 32],
76    provenance: ModuleProvenance,
77    harn_version: &'static str,
78    codegen_fingerprint: &'static str,
79    optimizations_enabled: bool,
80}
81
82impl PreparedModuleCacheKey {
83    /// `source_hash` is the same SHA-256 that names the module's on-disk
84    /// artifact. Keying on it rather than a second digest of the same bytes
85    /// means a warm module load hashes its source once, and lets a caller
86    /// holding a recorded digest find a prepared artifact without the bytes.
87    fn new(canonical_path: PathBuf, source_hash: [u8; 32], provenance: ModuleProvenance) -> Self {
88        Self {
89            canonical_path,
90            source_hash,
91            provenance,
92            harn_version: crate::bytecode_cache::HARN_VERSION,
93            codegen_fingerprint: crate::bytecode_cache::CODEGEN_FINGERPRINT,
94            optimizations_enabled: crate::compiler::CompilerOptions::from_env()
95                .optimizations_enabled(),
96        }
97    }
98}
99
100#[derive(Default)]
101struct PreparedModuleCacheInner {
102    entries: BTreeMap<PreparedModuleCacheKey, Arc<PreparedModuleArtifact>>,
103    insertion_order: VecDeque<PreparedModuleCacheKey>,
104    hits: u64,
105    misses: u64,
106    insertions: u64,
107    evictions: u64,
108}
109
110/// Typed counters for a [`PreparedModuleCache`] lifetime.
111#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
112#[non_exhaustive]
113pub struct PreparedModuleCacheStats {
114    pub hits: u64,
115    pub misses: u64,
116    pub insertions: u64,
117    pub evictions: u64,
118    pub entries: usize,
119}
120
121/// A bounded, shareable cache of immutable module bytecode templates.
122///
123/// The handle is explicit so embedders can scope reuse to one test suite,
124/// worker, watch generation, or VM baseline. Dropping the final handle releases
125/// every prepared artifact; historical user source never accumulates globally.
126#[derive(Clone)]
127pub struct PreparedModuleCache {
128    max_entries: NonZeroUsize,
129    inner: Arc<Mutex<PreparedModuleCacheInner>>,
130}
131
132impl Default for PreparedModuleCache {
133    fn default() -> Self {
134        Self::with_capacity(
135            NonZeroUsize::new(DEFAULT_MAX_ENTRIES).expect("non-zero cache capacity"),
136        )
137    }
138}
139
140impl PreparedModuleCache {
141    pub fn with_capacity(max_entries: NonZeroUsize) -> Self {
142        Self {
143            max_entries,
144            inner: Arc::new(Mutex::new(PreparedModuleCacheInner::default())),
145        }
146    }
147
148    pub fn stats(&self) -> PreparedModuleCacheStats {
149        let inner = self.inner.lock();
150        PreparedModuleCacheStats {
151            hits: inner.hits,
152            misses: inner.misses,
153            insertions: inner.insertions,
154            evictions: inner.evictions,
155            entries: inner.entries.len(),
156        }
157    }
158
159    /// Prepare every import reachable from `roots` without instantiating or
160    /// executing module state.
161    ///
162    /// Root files themselves are entry programs, not runtime imports, so only
163    /// their transitive import closure is prepared. Invalid modules are left
164    /// uncached for the canonical VM load to diagnose.
165    pub fn prepare_import_graph(&self, roots: &[PathBuf]) -> ModulePhaseStats {
166        self.prepare_import_graph_with_provenance(roots, ModuleProvenance::User)
167    }
168
169    /// Prepare a Rust-embedder-selected host-dispatch graph without making its
170    /// bytecode visible to ordinary user imports. The in-memory cache key
171    /// retains provenance, and fresh VMs still instantiate independent module
172    /// state from the immutable artifacts.
173    pub fn prepare_trusted_host_dispatch_import_graph(
174        &self,
175        roots: &[PathBuf],
176    ) -> ModulePhaseStats {
177        self.prepare_import_graph_with_provenance(roots, ModuleProvenance::TrustedHostDispatch)
178    }
179
180    fn prepare_import_graph_with_provenance(
181        &self,
182        roots: &[PathBuf],
183        provenance: ModuleProvenance,
184    ) -> ModulePhaseStats {
185        if roots.is_empty() {
186            return ModulePhaseStats::default();
187        }
188
189        let graph = harn_modules::build(roots);
190        let root_paths = roots
191            .iter()
192            .map(|path| harn_modules::canonical_path(path))
193            .collect::<std::collections::HashSet<_>>();
194        let recorder = ModulePhaseRecorder::new();
195
196        for path in graph.module_paths() {
197            if root_paths.contains(&harn_modules::canonical_path(&path)) {
198                continue;
199            }
200            if path.to_str().is_some_and(|path| path.starts_with("<std>/")) {
201                let _ = crate::vm::prepare_stdlib_module_artifact(&path, Some(&recorder));
202                continue;
203            }
204
205            let source = {
206                let _load_span = recorder.load_span();
207                match crate::module_source::read(&path) {
208                    Ok(source) => source,
209                    Err(_) => continue,
210                }
211            };
212            let mut imported_enum_candidates = graph
213                .imported_names_by_kind_for_file(&path, DefKind::Enum)
214                .unwrap_or_default()
215                .into_iter()
216                .collect::<Vec<_>>();
217            imported_enum_candidates.sort_unstable();
218            let mut imported_source_callable_names = graph
219                .imported_callable_names_for_file(&path)
220                .unwrap_or_default()
221                .into_iter()
222                .collect::<Vec<_>>();
223            imported_source_callable_names.sort_unstable();
224            let canonical = harn_modules::canonical_path(&path);
225            let _ = self.prepare(
226                &path,
227                &canonical,
228                &source,
229                Some(&imported_enum_candidates),
230                Some(&imported_source_callable_names),
231                Some(&recorder),
232                provenance,
233            );
234        }
235
236        recorder.snapshot()
237    }
238
239    pub(crate) fn get(
240        &self,
241        canonical_path: &Path,
242        source_hash: [u8; 32],
243        provenance: ModuleProvenance,
244    ) -> Option<Arc<PreparedModuleArtifact>> {
245        let key =
246            PreparedModuleCacheKey::new(canonical_path.to_path_buf(), source_hash, provenance);
247        let mut inner = self.inner.lock();
248        let artifact = inner.entries.get(&key).cloned();
249        if artifact.is_some() {
250            inner.hits = inner.hits.saturating_add(1);
251        } else {
252            inner.misses = inner.misses.saturating_add(1);
253        }
254        artifact
255    }
256
257    pub(crate) fn insert(
258        &self,
259        canonical_path: PathBuf,
260        source_hash: [u8; 32],
261        artifact: Arc<PreparedModuleArtifact>,
262    ) -> Arc<PreparedModuleArtifact> {
263        let key = PreparedModuleCacheKey::new(canonical_path, source_hash, artifact.provenance);
264        let mut inner = self.inner.lock();
265        if let Some(existing) = inner.entries.get(&key) {
266            return Arc::clone(existing);
267        }
268        while inner.entries.len() >= self.max_entries.get() {
269            let Some(oldest) = inner.insertion_order.pop_front() else {
270                break;
271            };
272            if inner.entries.remove(&oldest).is_some() {
273                inner.evictions = inner.evictions.saturating_add(1);
274            }
275        }
276        inner.insertion_order.push_back(key.clone());
277        inner.entries.insert(key, Arc::clone(&artifact));
278        inner.insertions = inner.insertions.saturating_add(1);
279        artifact
280    }
281
282    pub(crate) fn prepare(
283        &self,
284        source_path: &Path,
285        canonical_path: &Path,
286        source: &ModuleSource,
287        imported_enum_candidates: Option<&[String]>,
288        imported_source_callable_names: Option<&[String]>,
289        recorder: Option<&ModulePhaseRecorder>,
290        provenance: ModuleProvenance,
291    ) -> Result<Arc<PreparedModuleArtifact>, VmError> {
292        let prepared = {
293            let _load_span = recorder.map(ModulePhaseRecorder::load_span);
294            self.get(canonical_path, source.sha256(), provenance)
295        };
296        if let Some(prepared) = prepared {
297            return Ok(prepared);
298        }
299
300        // Disk cache hits skip parse + compile. The scoped prepared cache
301        // additionally skips deserialization and chunk hydration on later
302        // fresh VMs without sharing any runtime module state.
303        let cached = if provenance == ModuleProvenance::TrustedHostDispatch {
304            let mut compile_span = recorder.map(ModulePhaseRecorder::compile_span);
305            let compiled = match (imported_enum_candidates, imported_source_callable_names) {
306                (Some(enum_candidates), Some(callable_names)) => {
307                    compile_trusted_host_dispatch_module_artifact_from_source_with_imported_symbols(
308                        source_path,
309                        source.as_str(),
310                        enum_candidates.iter().cloned(),
311                        callable_names.iter().cloned(),
312                    )?
313                }
314                _ => compile_trusted_host_dispatch_module_artifact_from_source(
315                    source_path,
316                    source.as_str(),
317                )?,
318            };
319            if let Some(span) = &mut compile_span {
320                span.mark_compile_succeeded();
321            }
322            drop(compile_span);
323            compiled
324        } else {
325            // Only ordinary user bytecode enters the process-wide disk cache.
326            // Trusted host-dispatch artifacts remain in this explicitly scoped,
327            // provenance-keyed in-memory cache.
328            let lookup = {
329                let _load_span = recorder.map(ModulePhaseRecorder::load_span);
330                crate::bytecode_cache::load_module(source_path, source)
331            };
332            if let Some(artifact) = lookup.artifact {
333                artifact
334            } else {
335                let mut compile_span = recorder.map(ModulePhaseRecorder::compile_span);
336                let compiled = match (imported_enum_candidates, imported_source_callable_names) {
337                    (Some(enum_candidates), Some(callable_names)) => {
338                        compile_module_artifact_from_source_with_imported_symbols(
339                            source_path,
340                            source.as_str(),
341                            enum_candidates.iter().cloned(),
342                            callable_names.iter().cloned(),
343                        )?
344                    }
345                    _ => compile_module_artifact_from_source(source_path, source.as_str())?,
346                };
347                if let Some(span) = &mut compile_span {
348                    span.mark_compile_succeeded();
349                }
350                drop(compile_span);
351                if let Err(err) = crate::bytecode_cache::store_module(&lookup.key, &compiled) {
352                    if std::env::var_os("HARN_BYTECODE_CACHE_DEBUG").is_some() {
353                        eprintln!(
354                            "[harn] module cache write skipped for {}: {err}",
355                            source_path.display()
356                        );
357                    }
358                }
359                compiled
360            }
361        };
362        let prepared = {
363            let _load_span = recorder.map(ModulePhaseRecorder::load_span);
364            Arc::new(PreparedModuleArtifact::from_cached(cached))
365        };
366        Ok(self.insert(canonical_path.to_path_buf(), source.sha256(), prepared))
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use crate::module_artifact::{compile_module_artifact_from_source, ModuleImportBinding};
374    use crate::module_source::ModuleSource;
375    use harn_parser::TypeExpr;
376
377    fn named_list_element(type_expr: &Option<TypeExpr>) -> &str {
378        match type_expr {
379            Some(TypeExpr::List(inner)) => match inner.as_ref() {
380                TypeExpr::Named(name) => name,
381                other => panic!("expected named list element, got {other:?}"),
382            },
383            other => panic!("expected list parameter type, got {other:?}"),
384        }
385    }
386
387    fn empty_artifact_with_provenance(provenance: ModuleProvenance) -> Arc<PreparedModuleArtifact> {
388        Arc::new(PreparedModuleArtifact::from_cached(ModuleArtifact {
389            provenance,
390            imports: Vec::new(),
391            type_schema_init_chunks: Vec::new(),
392            init_chunk: None,
393            functions: BTreeMap::new(),
394            public_exports: BTreeMap::new(),
395            public_value_names: Default::default(),
396            public_type_names: Default::default(),
397        }))
398    }
399
400    fn empty_artifact() -> Arc<PreparedModuleArtifact> {
401        empty_artifact_with_provenance(ModuleProvenance::User)
402    }
403
404    #[test]
405    fn ordinary_lookup_cannot_reuse_privileged_wire_bytecode() {
406        let cache = PreparedModuleCache::default();
407        let source = ModuleSource::from_text("const value = 1");
408        let _ = cache.insert(
409            PathBuf::from("same.harn"),
410            source.sha256(),
411            empty_artifact_with_provenance(ModuleProvenance::PrivilegedWire),
412        );
413        assert!(
414            cache
415                .get(
416                    Path::new("same.harn"),
417                    source.sha256(),
418                    ModuleProvenance::User,
419                )
420                .is_none(),
421            "user module lookup must be provenance-separated"
422        );
423        assert!(cache
424            .get(
425                Path::new("same.harn"),
426                source.sha256(),
427                ModuleProvenance::PrivilegedWire,
428            )
429            .is_some());
430    }
431
432    #[test]
433    fn bounded_cache_evicts_oldest_exact_key() {
434        let cache = PreparedModuleCache::with_capacity(NonZeroUsize::new(1).unwrap());
435        let first_source = ModuleSource::from_text("pub fn first() { 1 }");
436        let second_source = ModuleSource::from_text("pub fn second() { 2 }");
437        let first = empty_artifact();
438        let _ = cache.insert(PathBuf::from("first.harn"), first_source.sha256(), first);
439        let _ = cache.insert(
440            PathBuf::from("second.harn"),
441            second_source.sha256(),
442            empty_artifact(),
443        );
444
445        assert!(cache
446            .get(
447                Path::new("first.harn"),
448                first_source.sha256(),
449                ModuleProvenance::User,
450            )
451            .is_none());
452        assert!(cache
453            .get(
454                Path::new("second.harn"),
455                second_source.sha256(),
456                ModuleProvenance::User,
457            )
458            .is_some());
459        assert_eq!(cache.stats().evictions, 1);
460        assert_eq!(cache.stats().entries, 1);
461    }
462
463    #[test]
464    fn cache_key_separates_compiler_configuration() {
465        let path = PathBuf::from("module.harn");
466        let key = PreparedModuleCacheKey::new(
467            path,
468            ModuleSource::from_text("pub fn value() { 1 }").sha256(),
469            ModuleProvenance::User,
470        );
471        let mut other_compiler = key.clone();
472        other_compiler.optimizations_enabled = !key.optimizations_enabled;
473
474        assert_ne!(key, other_compiler);
475    }
476
477    #[test]
478    fn dropping_last_cache_handle_releases_prepared_artifacts() {
479        let cache = PreparedModuleCache::default();
480        let path = PathBuf::from("module.harn");
481        let source = ModuleSource::from_text("pub fn value() { 1 }");
482        let artifact = empty_artifact();
483        let weak = Arc::downgrade(&artifact);
484        let _ = cache.insert(path, source.sha256(), artifact);
485        let clone = cache.clone();
486
487        drop(cache);
488        assert!(weak.upgrade().is_some());
489        drop(clone);
490        assert!(weak.upgrade().is_none());
491    }
492
493    #[test]
494    fn hydration_moves_module_owned_storage() {
495        let source = r#"
496import { assert_eq } from "std/testing"
497pub type Result = {value: int}
498pub const value = 1
499pub fn answer(items: list<string>) {
500  fn nested() { return 42 }
501  return items
502}
503"#;
504        let artifact = compile_module_artifact_from_source(Path::new("owned.harn"), source)
505            .expect("compile typed module artifact");
506
507        let imports = artifact.imports.as_ptr();
508        let import_path = artifact.imports[0].path.as_ptr();
509        let ModuleImportBinding::Selected(selected) = &artifact.imports[0].binding else {
510            panic!("expected selective import");
511        };
512        let selected_names = selected.as_ptr();
513        let selected_name = selected[0].as_ptr();
514        let init_code = artifact.init_chunk.as_ref().unwrap().code.as_ptr();
515        let schema_init_codes = artifact
516            .type_schema_init_chunks
517            .iter()
518            .map(|chunk| chunk.code.as_ptr())
519            .collect::<Vec<_>>();
520        let (function_key, function) = artifact.functions.first_key_value().unwrap();
521        let function_key = function_key.as_ptr();
522        let function_name = function.name.as_ptr();
523        let function_code = function.chunk.code.as_ptr();
524        let param_name = function.params[0].name.as_ptr();
525        let param_type_name = named_list_element(&function.params[0].type_expr).as_ptr();
526        let nested_name = function.chunk.functions[0].name.as_ptr();
527        let nested_code = function.chunk.functions[0].chunk.code.as_ptr();
528        let public_export_name = artifact
529            .public_exports
530            .get_key_value("answer")
531            .unwrap()
532            .0
533            .as_ptr();
534        let public_export_kind = *artifact.public_exports.get("answer").unwrap();
535        let public_value_name = artifact.public_value_names.get("value").unwrap().as_ptr();
536        let public_type_name = artifact.public_type_names.get("Result").unwrap().as_ptr();
537        let hydrated = PreparedModuleArtifact::from_cached(artifact);
538
539        assert_eq!(hydrated.imports.as_ptr(), imports);
540        assert_eq!(hydrated.imports[0].path.as_ptr(), import_path);
541        let ModuleImportBinding::Selected(selected) = &hydrated.imports[0].binding else {
542            panic!("expected selective import");
543        };
544        assert_eq!(selected.as_ptr(), selected_names);
545        assert_eq!(selected[0].as_ptr(), selected_name);
546        assert_eq!(
547            hydrated.init_chunk.as_ref().unwrap().code.as_ptr(),
548            init_code
549        );
550        assert_eq!(
551            hydrated
552                .type_schema_init_chunks
553                .iter()
554                .map(|chunk| chunk.code.as_ptr())
555                .collect::<Vec<_>>(),
556            schema_init_codes
557        );
558        let (hydrated_function_key, hydrated_function) =
559            hydrated.functions.first_key_value().unwrap();
560        assert_eq!(hydrated_function_key.as_ptr(), function_key);
561        assert_eq!(hydrated_function.name.as_ptr(), function_name);
562        assert_eq!(hydrated_function.chunk.code.as_ptr(), function_code);
563        assert_eq!(hydrated_function.params[0].name.as_ptr(), param_name);
564        assert_eq!(
565            named_list_element(&hydrated_function.params[0].type_expr).as_ptr(),
566            param_type_name
567        );
568        assert_eq!(
569            hydrated_function.chunk.functions[0].name.as_ptr(),
570            nested_name
571        );
572        assert_eq!(
573            hydrated_function.chunk.functions[0].chunk.code.as_ptr(),
574            nested_code
575        );
576        assert_eq!(
577            hydrated
578                .public_exports
579                .get_key_value("answer")
580                .unwrap()
581                .0
582                .as_ptr(),
583            public_export_name
584        );
585        assert_eq!(
586            hydrated.public_exports.get("answer"),
587            Some(&public_export_kind)
588        );
589        assert_eq!(
590            hydrated.public_value_names.get("value").unwrap().as_ptr(),
591            public_value_name
592        );
593        assert_eq!(
594            hydrated.public_type_names.get("Result").unwrap().as_ptr(),
595            public_type_name
596        );
597    }
598}