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