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