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