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    compile_trusted_host_dispatch_module_artifact_from_source,
19    compile_trusted_host_dispatch_module_artifact_from_source_with_imported_enums, ModuleArtifact,
20    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 canonical = harn_modules::canonical_path(&path);
219            let _ = self.prepare(
220                &path,
221                &canonical,
222                &source,
223                Some(&imported_enum_candidates),
224                Some(&recorder),
225                provenance,
226            );
227        }
228
229        recorder.snapshot()
230    }
231
232    pub(crate) fn get(
233        &self,
234        canonical_path: &Path,
235        source_hash: [u8; 32],
236        provenance: ModuleProvenance,
237    ) -> Option<Arc<PreparedModuleArtifact>> {
238        let key =
239            PreparedModuleCacheKey::new(canonical_path.to_path_buf(), source_hash, provenance);
240        let mut inner = self.inner.lock();
241        let artifact = inner.entries.get(&key).cloned();
242        if artifact.is_some() {
243            inner.hits = inner.hits.saturating_add(1);
244        } else {
245            inner.misses = inner.misses.saturating_add(1);
246        }
247        artifact
248    }
249
250    pub(crate) fn insert(
251        &self,
252        canonical_path: PathBuf,
253        source_hash: [u8; 32],
254        artifact: Arc<PreparedModuleArtifact>,
255    ) -> Arc<PreparedModuleArtifact> {
256        let key = PreparedModuleCacheKey::new(canonical_path, source_hash, artifact.provenance);
257        let mut inner = self.inner.lock();
258        if let Some(existing) = inner.entries.get(&key) {
259            return Arc::clone(existing);
260        }
261        while inner.entries.len() >= self.max_entries.get() {
262            let Some(oldest) = inner.insertion_order.pop_front() else {
263                break;
264            };
265            if inner.entries.remove(&oldest).is_some() {
266                inner.evictions = inner.evictions.saturating_add(1);
267            }
268        }
269        inner.insertion_order.push_back(key.clone());
270        inner.entries.insert(key, Arc::clone(&artifact));
271        inner.insertions = inner.insertions.saturating_add(1);
272        artifact
273    }
274
275    pub(crate) fn prepare(
276        &self,
277        source_path: &Path,
278        canonical_path: &Path,
279        source: &ModuleSource,
280        imported_enum_candidates: Option<&[String]>,
281        recorder: Option<&ModulePhaseRecorder>,
282        provenance: ModuleProvenance,
283    ) -> Result<Arc<PreparedModuleArtifact>, VmError> {
284        let prepared = {
285            let _load_span = recorder.map(ModulePhaseRecorder::load_span);
286            self.get(canonical_path, source.sha256(), provenance)
287        };
288        if let Some(prepared) = prepared {
289            return Ok(prepared);
290        }
291
292        // Disk cache hits skip parse + compile. The scoped prepared cache
293        // additionally skips deserialization and chunk hydration on later
294        // fresh VMs without sharing any runtime module state.
295        let cached = if provenance == ModuleProvenance::TrustedHostDispatch {
296            let mut compile_span = recorder.map(ModulePhaseRecorder::compile_span);
297            let compiled = match imported_enum_candidates {
298                Some(candidates) => {
299                    compile_trusted_host_dispatch_module_artifact_from_source_with_imported_enums(
300                        source_path,
301                        source.as_str(),
302                        candidates.iter().cloned(),
303                    )?
304                }
305                None => compile_trusted_host_dispatch_module_artifact_from_source(
306                    source_path,
307                    source.as_str(),
308                )?,
309            };
310            if let Some(span) = &mut compile_span {
311                span.mark_compile_succeeded();
312            }
313            drop(compile_span);
314            compiled
315        } else {
316            // Only ordinary user bytecode enters the process-wide disk cache.
317            // Trusted host-dispatch artifacts remain in this explicitly scoped,
318            // provenance-keyed in-memory cache.
319            let lookup = {
320                let _load_span = recorder.map(ModulePhaseRecorder::load_span);
321                crate::bytecode_cache::load_module(source_path, source)
322            };
323            if let Some(artifact) = lookup.artifact {
324                artifact
325            } else {
326                let mut compile_span = recorder.map(ModulePhaseRecorder::compile_span);
327                let compiled = match imported_enum_candidates {
328                    Some(candidates) => compile_module_artifact_from_source_with_imported_enums(
329                        source_path,
330                        source.as_str(),
331                        candidates.iter().cloned(),
332                    )?,
333                    None => compile_module_artifact_from_source(source_path, source.as_str())?,
334                };
335                if let Some(span) = &mut compile_span {
336                    span.mark_compile_succeeded();
337                }
338                drop(compile_span);
339                if let Err(err) = crate::bytecode_cache::store_module(&lookup.key, &compiled) {
340                    if std::env::var_os("HARN_BYTECODE_CACHE_DEBUG").is_some() {
341                        eprintln!(
342                            "[harn] module cache write skipped for {}: {err}",
343                            source_path.display()
344                        );
345                    }
346                }
347                compiled
348            }
349        };
350        let prepared = {
351            let _load_span = recorder.map(ModulePhaseRecorder::load_span);
352            Arc::new(PreparedModuleArtifact::from_cached(cached))
353        };
354        Ok(self.insert(canonical_path.to_path_buf(), source.sha256(), prepared))
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use crate::module_artifact::{compile_module_artifact_from_source, ModuleImportBinding};
362    use crate::module_source::ModuleSource;
363    use harn_parser::TypeExpr;
364
365    fn named_list_element(type_expr: &Option<TypeExpr>) -> &str {
366        match type_expr {
367            Some(TypeExpr::List(inner)) => match inner.as_ref() {
368                TypeExpr::Named(name) => name,
369                other => panic!("expected named list element, got {other:?}"),
370            },
371            other => panic!("expected list parameter type, got {other:?}"),
372        }
373    }
374
375    fn empty_artifact_with_provenance(provenance: ModuleProvenance) -> Arc<PreparedModuleArtifact> {
376        Arc::new(PreparedModuleArtifact::from_cached(ModuleArtifact {
377            provenance,
378            imports: Vec::new(),
379            type_schema_init_chunks: Vec::new(),
380            init_chunk: None,
381            functions: BTreeMap::new(),
382            public_exports: BTreeMap::new(),
383            public_value_names: Default::default(),
384            public_type_names: Default::default(),
385        }))
386    }
387
388    fn empty_artifact() -> Arc<PreparedModuleArtifact> {
389        empty_artifact_with_provenance(ModuleProvenance::User)
390    }
391
392    #[test]
393    fn ordinary_lookup_cannot_reuse_privileged_wire_bytecode() {
394        let cache = PreparedModuleCache::default();
395        let source = ModuleSource::from_text("const value = 1");
396        let _ = cache.insert(
397            PathBuf::from("same.harn"),
398            source.sha256(),
399            empty_artifact_with_provenance(ModuleProvenance::PrivilegedWire),
400        );
401        assert!(
402            cache
403                .get(
404                    Path::new("same.harn"),
405                    source.sha256(),
406                    ModuleProvenance::User,
407                )
408                .is_none(),
409            "user module lookup must be provenance-separated"
410        );
411        assert!(cache
412            .get(
413                Path::new("same.harn"),
414                source.sha256(),
415                ModuleProvenance::PrivilegedWire,
416            )
417            .is_some());
418    }
419
420    #[test]
421    fn bounded_cache_evicts_oldest_exact_key() {
422        let cache = PreparedModuleCache::with_capacity(NonZeroUsize::new(1).unwrap());
423        let first_source = ModuleSource::from_text("pub fn first() { 1 }");
424        let second_source = ModuleSource::from_text("pub fn second() { 2 }");
425        let first = empty_artifact();
426        let _ = cache.insert(PathBuf::from("first.harn"), first_source.sha256(), first);
427        let _ = cache.insert(
428            PathBuf::from("second.harn"),
429            second_source.sha256(),
430            empty_artifact(),
431        );
432
433        assert!(cache
434            .get(
435                Path::new("first.harn"),
436                first_source.sha256(),
437                ModuleProvenance::User,
438            )
439            .is_none());
440        assert!(cache
441            .get(
442                Path::new("second.harn"),
443                second_source.sha256(),
444                ModuleProvenance::User,
445            )
446            .is_some());
447        assert_eq!(cache.stats().evictions, 1);
448        assert_eq!(cache.stats().entries, 1);
449    }
450
451    #[test]
452    fn cache_key_separates_compiler_configuration() {
453        let path = PathBuf::from("module.harn");
454        let key = PreparedModuleCacheKey::new(
455            path,
456            ModuleSource::from_text("pub fn value() { 1 }").sha256(),
457            ModuleProvenance::User,
458        );
459        let mut other_compiler = key.clone();
460        other_compiler.optimizations_enabled = !key.optimizations_enabled;
461
462        assert_ne!(key, other_compiler);
463    }
464
465    #[test]
466    fn dropping_last_cache_handle_releases_prepared_artifacts() {
467        let cache = PreparedModuleCache::default();
468        let path = PathBuf::from("module.harn");
469        let source = ModuleSource::from_text("pub fn value() { 1 }");
470        let artifact = empty_artifact();
471        let weak = Arc::downgrade(&artifact);
472        let _ = cache.insert(path, source.sha256(), artifact);
473        let clone = cache.clone();
474
475        drop(cache);
476        assert!(weak.upgrade().is_some());
477        drop(clone);
478        assert!(weak.upgrade().is_none());
479    }
480
481    #[test]
482    fn hydration_moves_module_owned_storage() {
483        let source = r#"
484import { assert_eq } from "std/testing"
485pub type Result = {value: int}
486pub const value = 1
487pub fn answer(items: list<string>) {
488  fn nested() { return 42 }
489  return items
490}
491"#;
492        let artifact = compile_module_artifact_from_source(Path::new("owned.harn"), source)
493            .expect("compile typed module artifact");
494
495        let imports = artifact.imports.as_ptr();
496        let import_path = artifact.imports[0].path.as_ptr();
497        let ModuleImportBinding::Selected(selected) = &artifact.imports[0].binding else {
498            panic!("expected selective import");
499        };
500        let selected_names = selected.as_ptr();
501        let selected_name = selected[0].as_ptr();
502        let init_code = artifact.init_chunk.as_ref().unwrap().code.as_ptr();
503        let schema_init_codes = artifact
504            .type_schema_init_chunks
505            .iter()
506            .map(|chunk| chunk.code.as_ptr())
507            .collect::<Vec<_>>();
508        let (function_key, function) = artifact.functions.first_key_value().unwrap();
509        let function_key = function_key.as_ptr();
510        let function_name = function.name.as_ptr();
511        let function_code = function.chunk.code.as_ptr();
512        let param_name = function.params[0].name.as_ptr();
513        let param_type_name = named_list_element(&function.params[0].type_expr).as_ptr();
514        let nested_name = function.chunk.functions[0].name.as_ptr();
515        let nested_code = function.chunk.functions[0].chunk.code.as_ptr();
516        let public_export_name = artifact
517            .public_exports
518            .get_key_value("answer")
519            .unwrap()
520            .0
521            .as_ptr();
522        let public_export_kind = *artifact.public_exports.get("answer").unwrap();
523        let public_value_name = artifact.public_value_names.get("value").unwrap().as_ptr();
524        let public_type_name = artifact.public_type_names.get("Result").unwrap().as_ptr();
525        let hydrated = PreparedModuleArtifact::from_cached(artifact);
526
527        assert_eq!(hydrated.imports.as_ptr(), imports);
528        assert_eq!(hydrated.imports[0].path.as_ptr(), import_path);
529        let ModuleImportBinding::Selected(selected) = &hydrated.imports[0].binding else {
530            panic!("expected selective import");
531        };
532        assert_eq!(selected.as_ptr(), selected_names);
533        assert_eq!(selected[0].as_ptr(), selected_name);
534        assert_eq!(
535            hydrated.init_chunk.as_ref().unwrap().code.as_ptr(),
536            init_code
537        );
538        assert_eq!(
539            hydrated
540                .type_schema_init_chunks
541                .iter()
542                .map(|chunk| chunk.code.as_ptr())
543                .collect::<Vec<_>>(),
544            schema_init_codes
545        );
546        let (hydrated_function_key, hydrated_function) =
547            hydrated.functions.first_key_value().unwrap();
548        assert_eq!(hydrated_function_key.as_ptr(), function_key);
549        assert_eq!(hydrated_function.name.as_ptr(), function_name);
550        assert_eq!(hydrated_function.chunk.code.as_ptr(), function_code);
551        assert_eq!(hydrated_function.params[0].name.as_ptr(), param_name);
552        assert_eq!(
553            named_list_element(&hydrated_function.params[0].type_expr).as_ptr(),
554            param_type_name
555        );
556        assert_eq!(
557            hydrated_function.chunk.functions[0].name.as_ptr(),
558            nested_name
559        );
560        assert_eq!(
561            hydrated_function.chunk.functions[0].chunk.code.as_ptr(),
562            nested_code
563        );
564        assert_eq!(
565            hydrated
566                .public_exports
567                .get_key_value("answer")
568                .unwrap()
569                .0
570                .as_ptr(),
571            public_export_name
572        );
573        assert_eq!(
574            hydrated.public_exports.get("answer"),
575            Some(&public_export_kind)
576        );
577        assert_eq!(
578            hydrated.public_value_names.get("value").unwrap().as_ptr(),
579            public_value_name
580        );
581        assert_eq!(
582            hydrated.public_type_names.get("Result").unwrap().as_ptr(),
583            public_type_name
584        );
585    }
586}