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::{ModuleArtifact, ModuleImportSpec};
17const DEFAULT_MAX_ENTRIES: usize = 512;
18
19/// Immutable runtime form of one compiled module artifact.
20pub(crate) struct PreparedModuleArtifact {
21    pub(crate) imports: Vec<ModuleImportSpec>,
22    pub(crate) type_schema_init_chunk: Option<Arc<Chunk>>,
23    pub(crate) init_chunk: Option<Arc<Chunk>>,
24    pub(crate) functions: BTreeMap<String, Arc<CompiledFunction>>,
25    pub(crate) public_exports: BTreeMap<String, DefKind>,
26    pub(crate) public_value_names: std::collections::HashSet<String>,
27    pub(crate) public_type_names: std::collections::HashSet<String>,
28}
29
30impl PreparedModuleArtifact {
31    pub(crate) fn from_cached(artifact: ModuleArtifact) -> Self {
32        let ModuleArtifact {
33            imports,
34            type_schema_init_chunk,
35            init_chunk,
36            functions,
37            public_exports,
38            public_value_names,
39            public_type_names,
40        } = artifact;
41        let type_schema_init_chunk =
42            type_schema_init_chunk.map(|chunk| Arc::new(Chunk::from_cached(chunk)));
43        let init_chunk = init_chunk.map(|chunk| Arc::new(Chunk::from_cached(chunk)));
44        let functions = functions
45            .into_iter()
46            .map(|(name, function)| (name, Arc::new(CompiledFunction::from_cached(function))))
47            .collect();
48        Self {
49            imports,
50            type_schema_init_chunk,
51            init_chunk,
52            functions,
53            public_exports,
54            public_value_names,
55            public_type_names,
56        }
57    }
58}
59
60#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
61struct PreparedModuleCacheKey {
62    canonical_path: PathBuf,
63    source_hash: [u8; 32],
64    harn_version: &'static str,
65    codegen_fingerprint: &'static str,
66    optimizations_enabled: bool,
67}
68
69impl PreparedModuleCacheKey {
70    /// `source_hash` is the same SHA-256 that names the module's on-disk
71    /// artifact. Keying on it rather than a second digest of the same bytes
72    /// means a warm module load hashes its source once, and lets a caller
73    /// holding a recorded digest find a prepared artifact without the bytes.
74    fn new(canonical_path: PathBuf, source_hash: [u8; 32]) -> Self {
75        Self {
76            canonical_path,
77            source_hash,
78            harn_version: crate::bytecode_cache::HARN_VERSION,
79            codegen_fingerprint: crate::bytecode_cache::CODEGEN_FINGERPRINT,
80            optimizations_enabled: crate::compiler::CompilerOptions::from_env()
81                .optimizations_enabled(),
82        }
83    }
84}
85
86#[derive(Default)]
87struct PreparedModuleCacheInner {
88    entries: BTreeMap<PreparedModuleCacheKey, Arc<PreparedModuleArtifact>>,
89    insertion_order: VecDeque<PreparedModuleCacheKey>,
90    hits: u64,
91    misses: u64,
92    insertions: u64,
93    evictions: u64,
94}
95
96/// Typed counters for a [`PreparedModuleCache`] lifetime.
97#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
98#[non_exhaustive]
99pub struct PreparedModuleCacheStats {
100    pub hits: u64,
101    pub misses: u64,
102    pub insertions: u64,
103    pub evictions: u64,
104    pub entries: usize,
105}
106
107/// A bounded, shareable cache of immutable module bytecode templates.
108///
109/// The handle is explicit so embedders can scope reuse to one test suite,
110/// worker, watch generation, or VM baseline. Dropping the final handle releases
111/// every prepared artifact; historical user source never accumulates globally.
112#[derive(Clone)]
113pub struct PreparedModuleCache {
114    max_entries: NonZeroUsize,
115    inner: Arc<Mutex<PreparedModuleCacheInner>>,
116}
117
118impl Default for PreparedModuleCache {
119    fn default() -> Self {
120        Self::with_capacity(
121            NonZeroUsize::new(DEFAULT_MAX_ENTRIES).expect("non-zero cache capacity"),
122        )
123    }
124}
125
126impl PreparedModuleCache {
127    pub fn with_capacity(max_entries: NonZeroUsize) -> Self {
128        Self {
129            max_entries,
130            inner: Arc::new(Mutex::new(PreparedModuleCacheInner::default())),
131        }
132    }
133
134    pub fn stats(&self) -> PreparedModuleCacheStats {
135        let inner = self.inner.lock();
136        PreparedModuleCacheStats {
137            hits: inner.hits,
138            misses: inner.misses,
139            insertions: inner.insertions,
140            evictions: inner.evictions,
141            entries: inner.entries.len(),
142        }
143    }
144
145    pub(crate) fn get(
146        &self,
147        canonical_path: &Path,
148        source_hash: [u8; 32],
149    ) -> Option<Arc<PreparedModuleArtifact>> {
150        let key = PreparedModuleCacheKey::new(canonical_path.to_path_buf(), source_hash);
151        let mut inner = self.inner.lock();
152        let artifact = inner.entries.get(&key).cloned();
153        if artifact.is_some() {
154            inner.hits = inner.hits.saturating_add(1);
155        } else {
156            inner.misses = inner.misses.saturating_add(1);
157        }
158        artifact
159    }
160
161    pub(crate) fn insert(
162        &self,
163        canonical_path: PathBuf,
164        source_hash: [u8; 32],
165        artifact: Arc<PreparedModuleArtifact>,
166    ) -> Arc<PreparedModuleArtifact> {
167        let key = PreparedModuleCacheKey::new(canonical_path, source_hash);
168        let mut inner = self.inner.lock();
169        if let Some(existing) = inner.entries.get(&key) {
170            return Arc::clone(existing);
171        }
172        while inner.entries.len() >= self.max_entries.get() {
173            let Some(oldest) = inner.insertion_order.pop_front() else {
174                break;
175            };
176            if inner.entries.remove(&oldest).is_some() {
177                inner.evictions = inner.evictions.saturating_add(1);
178            }
179        }
180        inner.insertion_order.push_back(key.clone());
181        inner.entries.insert(key, Arc::clone(&artifact));
182        inner.insertions = inner.insertions.saturating_add(1);
183        artifact
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use crate::module_artifact::compile_module_artifact_from_source;
191    use crate::module_source::ModuleSource;
192    use harn_parser::TypeExpr;
193
194    fn named_list_element(type_expr: &Option<TypeExpr>) -> &str {
195        match type_expr {
196            Some(TypeExpr::List(inner)) => match inner.as_ref() {
197                TypeExpr::Named(name) => name,
198                other => panic!("expected named list element, got {other:?}"),
199            },
200            other => panic!("expected list parameter type, got {other:?}"),
201        }
202    }
203
204    fn empty_artifact() -> Arc<PreparedModuleArtifact> {
205        Arc::new(PreparedModuleArtifact::from_cached(ModuleArtifact {
206            imports: Vec::new(),
207            type_schema_init_chunk: None,
208            init_chunk: None,
209            functions: BTreeMap::new(),
210            public_exports: BTreeMap::new(),
211            public_value_names: Default::default(),
212            public_type_names: Default::default(),
213        }))
214    }
215
216    #[test]
217    fn bounded_cache_evicts_oldest_exact_key() {
218        let cache = PreparedModuleCache::with_capacity(NonZeroUsize::new(1).unwrap());
219        let first_source = ModuleSource::from_text("pub fn first() { 1 }");
220        let second_source = ModuleSource::from_text("pub fn second() { 2 }");
221        let first = empty_artifact();
222        let _ = cache.insert(PathBuf::from("first.harn"), first_source.sha256(), first);
223        let _ = cache.insert(
224            PathBuf::from("second.harn"),
225            second_source.sha256(),
226            empty_artifact(),
227        );
228
229        assert!(cache
230            .get(Path::new("first.harn"), first_source.sha256())
231            .is_none());
232        assert!(cache
233            .get(Path::new("second.harn"), second_source.sha256())
234            .is_some());
235        assert_eq!(cache.stats().evictions, 1);
236        assert_eq!(cache.stats().entries, 1);
237    }
238
239    #[test]
240    fn cache_key_separates_compiler_configuration() {
241        let path = PathBuf::from("module.harn");
242        let key = PreparedModuleCacheKey::new(
243            path,
244            ModuleSource::from_text("pub fn value() { 1 }").sha256(),
245        );
246        let mut other_compiler = key.clone();
247        other_compiler.optimizations_enabled = !key.optimizations_enabled;
248
249        assert_ne!(key, other_compiler);
250    }
251
252    #[test]
253    fn dropping_last_cache_handle_releases_prepared_artifacts() {
254        let cache = PreparedModuleCache::default();
255        let path = PathBuf::from("module.harn");
256        let source = ModuleSource::from_text("pub fn value() { 1 }");
257        let artifact = empty_artifact();
258        let weak = Arc::downgrade(&artifact);
259        let _ = cache.insert(path, source.sha256(), artifact);
260        let clone = cache.clone();
261
262        drop(cache);
263        assert!(weak.upgrade().is_some());
264        drop(clone);
265        assert!(weak.upgrade().is_none());
266    }
267
268    #[test]
269    fn hydration_moves_module_owned_storage() {
270        let source = r#"
271import { assert_eq } from "std/testing"
272pub type Result = {value: int}
273pub const value = 1
274pub fn answer(items: list<string>) {
275  fn nested() { return 42 }
276  return items
277}
278"#;
279        let artifact = compile_module_artifact_from_source(Path::new("owned.harn"), source)
280            .expect("compile typed module artifact");
281
282        let imports = artifact.imports.as_ptr();
283        let import_path = artifact.imports[0].path.as_ptr();
284        let selected_names = artifact.imports[0]
285            .selected_names
286            .as_ref()
287            .unwrap()
288            .as_ptr();
289        let selected_name = artifact.imports[0].selected_names.as_ref().unwrap()[0].as_ptr();
290        let init_code = artifact.init_chunk.as_ref().unwrap().code.as_ptr();
291        let schema_init_code = artifact
292            .type_schema_init_chunk
293            .as_ref()
294            .unwrap()
295            .code
296            .as_ptr();
297        let (function_key, function) = artifact.functions.first_key_value().unwrap();
298        let function_key = function_key.as_ptr();
299        let function_name = function.name.as_ptr();
300        let function_code = function.chunk.code.as_ptr();
301        let param_name = function.params[0].name.as_ptr();
302        let param_type_name = named_list_element(&function.params[0].type_expr).as_ptr();
303        let nested_name = function.chunk.functions[0].name.as_ptr();
304        let nested_code = function.chunk.functions[0].chunk.code.as_ptr();
305        let public_export_name = artifact
306            .public_exports
307            .get_key_value("answer")
308            .unwrap()
309            .0
310            .as_ptr();
311        let public_export_kind = *artifact.public_exports.get("answer").unwrap();
312        let public_value_name = artifact.public_value_names.get("value").unwrap().as_ptr();
313        let public_type_name = artifact.public_type_names.get("Result").unwrap().as_ptr();
314        let hydrated = PreparedModuleArtifact::from_cached(artifact);
315
316        assert_eq!(hydrated.imports.as_ptr(), imports);
317        assert_eq!(hydrated.imports[0].path.as_ptr(), import_path);
318        assert_eq!(
319            hydrated.imports[0]
320                .selected_names
321                .as_ref()
322                .unwrap()
323                .as_ptr(),
324            selected_names
325        );
326        assert_eq!(
327            hydrated.imports[0].selected_names.as_ref().unwrap()[0].as_ptr(),
328            selected_name
329        );
330        assert_eq!(
331            hydrated.init_chunk.as_ref().unwrap().code.as_ptr(),
332            init_code
333        );
334        assert_eq!(
335            hydrated
336                .type_schema_init_chunk
337                .as_ref()
338                .unwrap()
339                .code
340                .as_ptr(),
341            schema_init_code
342        );
343        let (hydrated_function_key, hydrated_function) =
344            hydrated.functions.first_key_value().unwrap();
345        assert_eq!(hydrated_function_key.as_ptr(), function_key);
346        assert_eq!(hydrated_function.name.as_ptr(), function_name);
347        assert_eq!(hydrated_function.chunk.code.as_ptr(), function_code);
348        assert_eq!(hydrated_function.params[0].name.as_ptr(), param_name);
349        assert_eq!(
350            named_list_element(&hydrated_function.params[0].type_expr).as_ptr(),
351            param_type_name
352        );
353        assert_eq!(
354            hydrated_function.chunk.functions[0].name.as_ptr(),
355            nested_name
356        );
357        assert_eq!(
358            hydrated_function.chunk.functions[0].chunk.code.as_ptr(),
359            nested_code
360        );
361        assert_eq!(
362            hydrated
363                .public_exports
364                .get_key_value("answer")
365                .unwrap()
366                .0
367                .as_ptr(),
368            public_export_name
369        );
370        assert_eq!(
371            hydrated.public_exports.get("answer"),
372            Some(&public_export_kind)
373        );
374        assert_eq!(
375            hydrated.public_value_names.get("value").unwrap().as_ptr(),
376            public_value_name
377        );
378        assert_eq!(
379            hydrated.public_type_names.get("Result").unwrap().as_ptr(),
380            public_type_name
381        );
382    }
383}