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