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 parking_lot::Mutex;
13
14use crate::chunk::{Chunk, CompiledFunction};
15use crate::module_artifact::{ModuleArtifact, ModuleImportSpec};
16
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_names: std::collections::HashSet<String>,
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_names,
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_names,
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    fn new(canonical_path: PathBuf, source: &str) -> Self {
71        Self {
72            canonical_path,
73            source_hash: *blake3::hash(source.as_bytes()).as_bytes(),
74            harn_version: crate::bytecode_cache::HARN_VERSION,
75            codegen_fingerprint: crate::bytecode_cache::CODEGEN_FINGERPRINT,
76            optimizations_enabled: crate::compiler::CompilerOptions::from_env()
77                .optimizations_enabled(),
78        }
79    }
80}
81
82#[derive(Default)]
83struct PreparedModuleCacheInner {
84    entries: BTreeMap<PreparedModuleCacheKey, Arc<PreparedModuleArtifact>>,
85    insertion_order: VecDeque<PreparedModuleCacheKey>,
86    hits: u64,
87    misses: u64,
88    insertions: u64,
89    evictions: u64,
90}
91
92/// Typed counters for a [`PreparedModuleCache`] lifetime.
93#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
94#[non_exhaustive]
95pub struct PreparedModuleCacheStats {
96    pub hits: u64,
97    pub misses: u64,
98    pub insertions: u64,
99    pub evictions: u64,
100    pub entries: usize,
101}
102
103/// A bounded, shareable cache of immutable module bytecode templates.
104///
105/// The handle is explicit so embedders can scope reuse to one test suite,
106/// worker, watch generation, or VM baseline. Dropping the final handle releases
107/// every prepared artifact; historical user source never accumulates globally.
108#[derive(Clone)]
109pub struct PreparedModuleCache {
110    max_entries: NonZeroUsize,
111    inner: Arc<Mutex<PreparedModuleCacheInner>>,
112}
113
114impl Default for PreparedModuleCache {
115    fn default() -> Self {
116        Self::with_capacity(
117            NonZeroUsize::new(DEFAULT_MAX_ENTRIES).expect("non-zero cache capacity"),
118        )
119    }
120}
121
122impl PreparedModuleCache {
123    pub fn with_capacity(max_entries: NonZeroUsize) -> Self {
124        Self {
125            max_entries,
126            inner: Arc::new(Mutex::new(PreparedModuleCacheInner::default())),
127        }
128    }
129
130    pub fn stats(&self) -> PreparedModuleCacheStats {
131        let inner = self.inner.lock();
132        PreparedModuleCacheStats {
133            hits: inner.hits,
134            misses: inner.misses,
135            insertions: inner.insertions,
136            evictions: inner.evictions,
137            entries: inner.entries.len(),
138        }
139    }
140
141    pub(crate) fn get(
142        &self,
143        canonical_path: &Path,
144        source: &str,
145    ) -> Option<Arc<PreparedModuleArtifact>> {
146        let key = PreparedModuleCacheKey::new(canonical_path.to_path_buf(), source);
147        let mut inner = self.inner.lock();
148        let artifact = inner.entries.get(&key).cloned();
149        if artifact.is_some() {
150            inner.hits = inner.hits.saturating_add(1);
151        } else {
152            inner.misses = inner.misses.saturating_add(1);
153        }
154        artifact
155    }
156
157    pub(crate) fn insert(
158        &self,
159        canonical_path: PathBuf,
160        source: &str,
161        artifact: Arc<PreparedModuleArtifact>,
162    ) -> Arc<PreparedModuleArtifact> {
163        let key = PreparedModuleCacheKey::new(canonical_path, source);
164        let mut inner = self.inner.lock();
165        if let Some(existing) = inner.entries.get(&key) {
166            return Arc::clone(existing);
167        }
168        while inner.entries.len() >= self.max_entries.get() {
169            let Some(oldest) = inner.insertion_order.pop_front() else {
170                break;
171            };
172            if inner.entries.remove(&oldest).is_some() {
173                inner.evictions = inner.evictions.saturating_add(1);
174            }
175        }
176        inner.insertion_order.push_back(key.clone());
177        inner.entries.insert(key, Arc::clone(&artifact));
178        inner.insertions = inner.insertions.saturating_add(1);
179        artifact
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::module_artifact::compile_module_artifact_from_source;
187    use harn_parser::TypeExpr;
188
189    fn named_list_element(type_expr: &Option<TypeExpr>) -> &str {
190        match type_expr {
191            Some(TypeExpr::List(inner)) => match inner.as_ref() {
192                TypeExpr::Named(name) => name,
193                other => panic!("expected named list element, got {other:?}"),
194            },
195            other => panic!("expected list parameter type, got {other:?}"),
196        }
197    }
198
199    fn empty_artifact() -> Arc<PreparedModuleArtifact> {
200        Arc::new(PreparedModuleArtifact::from_cached(ModuleArtifact {
201            imports: Vec::new(),
202            type_schema_init_chunk: None,
203            init_chunk: None,
204            functions: BTreeMap::new(),
205            public_names: Default::default(),
206            public_value_names: Default::default(),
207            public_type_names: Default::default(),
208        }))
209    }
210
211    #[test]
212    fn bounded_cache_evicts_oldest_exact_key() {
213        let cache = PreparedModuleCache::with_capacity(NonZeroUsize::new(1).unwrap());
214        let first_source = "pub fn first() { 1 }";
215        let second_source = "pub fn second() { 2 }";
216        let first = empty_artifact();
217        let _ = cache.insert(PathBuf::from("first.harn"), first_source, first);
218        let _ = cache.insert(
219            PathBuf::from("second.harn"),
220            second_source,
221            empty_artifact(),
222        );
223
224        assert!(cache.get(Path::new("first.harn"), first_source).is_none());
225        assert!(cache.get(Path::new("second.harn"), second_source).is_some());
226        assert_eq!(cache.stats().evictions, 1);
227        assert_eq!(cache.stats().entries, 1);
228    }
229
230    #[test]
231    fn cache_key_separates_compiler_configuration() {
232        let path = PathBuf::from("module.harn");
233        let key = PreparedModuleCacheKey::new(path, "pub fn value() { 1 }");
234        let mut other_compiler = key.clone();
235        other_compiler.optimizations_enabled = !key.optimizations_enabled;
236
237        assert_ne!(key, other_compiler);
238    }
239
240    #[test]
241    fn dropping_last_cache_handle_releases_prepared_artifacts() {
242        let cache = PreparedModuleCache::default();
243        let path = PathBuf::from("module.harn");
244        let source = "pub fn value() { 1 }";
245        let artifact = empty_artifact();
246        let weak = Arc::downgrade(&artifact);
247        let _ = cache.insert(path, source, artifact);
248        let clone = cache.clone();
249
250        drop(cache);
251        assert!(weak.upgrade().is_some());
252        drop(clone);
253        assert!(weak.upgrade().is_none());
254    }
255
256    #[test]
257    fn hydration_moves_module_owned_storage() {
258        let source = r#"
259import { assert_eq } from "std/testing"
260pub type Result = {value: int}
261pub const value = 1
262pub fn answer(items: list<string>) {
263  fn nested() { return 42 }
264  return items
265}
266"#;
267        let artifact = compile_module_artifact_from_source(Path::new("owned.harn"), source)
268            .expect("compile typed module artifact");
269
270        let imports = artifact.imports.as_ptr();
271        let import_path = artifact.imports[0].path.as_ptr();
272        let selected_names = artifact.imports[0]
273            .selected_names
274            .as_ref()
275            .unwrap()
276            .as_ptr();
277        let selected_name = artifact.imports[0].selected_names.as_ref().unwrap()[0].as_ptr();
278        let init_code = artifact.init_chunk.as_ref().unwrap().code.as_ptr();
279        let schema_init_code = artifact
280            .type_schema_init_chunk
281            .as_ref()
282            .unwrap()
283            .code
284            .as_ptr();
285        let (function_key, function) = artifact.functions.first_key_value().unwrap();
286        let function_key = function_key.as_ptr();
287        let function_name = function.name.as_ptr();
288        let function_code = function.chunk.code.as_ptr();
289        let param_name = function.params[0].name.as_ptr();
290        let param_type_name = named_list_element(&function.params[0].type_expr).as_ptr();
291        let nested_name = function.chunk.functions[0].name.as_ptr();
292        let nested_code = function.chunk.functions[0].chunk.code.as_ptr();
293        let public_name = artifact.public_names.get("answer").unwrap().as_ptr();
294        let public_value_name = artifact.public_value_names.get("value").unwrap().as_ptr();
295        let public_type_name = artifact.public_type_names.get("Result").unwrap().as_ptr();
296        let hydrated = PreparedModuleArtifact::from_cached(artifact);
297
298        assert_eq!(hydrated.imports.as_ptr(), imports);
299        assert_eq!(hydrated.imports[0].path.as_ptr(), import_path);
300        assert_eq!(
301            hydrated.imports[0]
302                .selected_names
303                .as_ref()
304                .unwrap()
305                .as_ptr(),
306            selected_names
307        );
308        assert_eq!(
309            hydrated.imports[0].selected_names.as_ref().unwrap()[0].as_ptr(),
310            selected_name
311        );
312        assert_eq!(
313            hydrated.init_chunk.as_ref().unwrap().code.as_ptr(),
314            init_code
315        );
316        assert_eq!(
317            hydrated
318                .type_schema_init_chunk
319                .as_ref()
320                .unwrap()
321                .code
322                .as_ptr(),
323            schema_init_code
324        );
325        let (hydrated_function_key, hydrated_function) =
326            hydrated.functions.first_key_value().unwrap();
327        assert_eq!(hydrated_function_key.as_ptr(), function_key);
328        assert_eq!(hydrated_function.name.as_ptr(), function_name);
329        assert_eq!(hydrated_function.chunk.code.as_ptr(), function_code);
330        assert_eq!(hydrated_function.params[0].name.as_ptr(), param_name);
331        assert_eq!(
332            named_list_element(&hydrated_function.params[0].type_expr).as_ptr(),
333            param_type_name
334        );
335        assert_eq!(
336            hydrated_function.chunk.functions[0].name.as_ptr(),
337            nested_name
338        );
339        assert_eq!(
340            hydrated_function.chunk.functions[0].chunk.code.as_ptr(),
341            nested_code
342        );
343        assert_eq!(
344            hydrated.public_names.get("answer").unwrap().as_ptr(),
345            public_name
346        );
347        assert_eq!(
348            hydrated.public_value_names.get("value").unwrap().as_ptr(),
349            public_value_name
350        );
351        assert_eq!(
352            hydrated.public_type_names.get("Result").unwrap().as_ptr(),
353            public_type_name
354        );
355    }
356}