Skip to main content

shape_vm/
hot_reload.rs

1//! Hot-reload support for content-addressed function blobs.
2//!
3//! When source files change, only affected functions are recompiled.
4//! Old blobs remain valid for in-flight frames. New calls use updated blobs.
5
6use crate::bytecode::{FunctionBlob, FunctionHash};
7use std::collections::{HashMap, HashSet};
8use std::path::PathBuf;
9
10/// Tracks which blobs are active and manages hot-reload updates.
11pub struct HotReloader {
12    /// Current name -> hash mapping.
13    current_mappings: HashMap<String, FunctionHash>,
14    /// All known blobs (old versions kept until GC).
15    blob_store: HashMap<FunctionHash, FunctionBlob>,
16    /// Blobs that are referenced by active frames (not GC-eligible).
17    active_references: HashSet<FunctionHash>,
18    /// History of updates for rollback support.
19    update_history: Vec<ReloadEvent>,
20    /// Optional file watch paths.
21    watch_paths: Vec<PathBuf>,
22}
23
24/// Record of a hot-reload event.
25#[derive(Debug, Clone)]
26pub struct ReloadEvent {
27    pub timestamp: std::time::Instant,
28    pub old_hash: FunctionHash,
29    pub new_hash: FunctionHash,
30    pub function_name: String,
31}
32
33/// Result of applying a hot-reload update.
34#[derive(Debug)]
35pub struct ReloadResult {
36    pub functions_updated: Vec<String>,
37    pub functions_unchanged: Vec<String>,
38    pub old_blobs_retained: usize,
39}
40
41/// Patch describing a single function update.
42#[derive(Debug, Clone)]
43pub struct FunctionPatch {
44    pub function_name: String,
45    pub old_hash: Option<FunctionHash>,
46    pub new_blob: FunctionBlob,
47}
48
49impl HotReloader {
50    pub fn new() -> Self {
51        Self {
52            current_mappings: HashMap::new(),
53            blob_store: HashMap::new(),
54            active_references: HashSet::new(),
55            update_history: Vec::new(),
56            watch_paths: Vec::new(),
57        }
58    }
59
60    /// Register a function's current blob.
61    pub fn register_function(&mut self, name: String, blob: FunctionBlob) {
62        let hash = blob.content_hash;
63        self.current_mappings.insert(name, hash);
64        self.blob_store.insert(hash, blob);
65    }
66
67    /// Mark a blob hash as actively referenced (in a call frame).
68    pub fn add_active_reference(&mut self, hash: FunctionHash) {
69        self.active_references.insert(hash);
70    }
71
72    /// Remove an active reference (frame exited).
73    pub fn remove_active_reference(&mut self, hash: FunctionHash) {
74        self.active_references.remove(&hash);
75    }
76
77    /// Apply a set of function patches (updated blobs).
78    pub fn apply_patches(&mut self, patches: Vec<FunctionPatch>) -> ReloadResult {
79        let mut functions_updated = Vec::new();
80        let mut functions_unchanged = Vec::new();
81        let mut old_blobs_retained: usize = 0;
82
83        for patch in patches {
84            let new_hash = patch.new_blob.content_hash;
85
86            // Check if the function already maps to this exact hash (unchanged).
87            if let Some(&existing_hash) = self.current_mappings.get(&patch.function_name) {
88                if existing_hash == new_hash {
89                    functions_unchanged.push(patch.function_name);
90                    continue;
91                }
92
93                // Record the reload event.
94                self.update_history.push(ReloadEvent {
95                    timestamp: std::time::Instant::now(),
96                    old_hash: existing_hash,
97                    new_hash,
98                    function_name: patch.function_name.clone(),
99                });
100
101                // If the old blob is actively referenced, it stays in the store.
102                if self.active_references.contains(&existing_hash) {
103                    old_blobs_retained += 1;
104                }
105            }
106
107            // Update the mapping and store the new blob.
108            self.current_mappings
109                .insert(patch.function_name.clone(), new_hash);
110            self.blob_store.insert(new_hash, patch.new_blob);
111            functions_updated.push(patch.function_name);
112        }
113
114        ReloadResult {
115            functions_updated,
116            functions_unchanged,
117            old_blobs_retained,
118        }
119    }
120
121    /// Get the current hash for a function name.
122    pub fn current_hash(&self, name: &str) -> Option<&FunctionHash> {
123        self.current_mappings.get(name)
124    }
125
126    /// Get a blob by hash (works for both current and retained old blobs).
127    pub fn get_blob(&self, hash: &FunctionHash) -> Option<&FunctionBlob> {
128        self.blob_store.get(hash)
129    }
130
131    /// Run garbage collection: remove old blobs with no active references.
132    ///
133    /// Returns the number of blobs removed.
134    pub fn gc(&mut self) -> usize {
135        // Collect the set of hashes that are currently mapped (live).
136        let live_hashes: HashSet<FunctionHash> = self.current_mappings.values().copied().collect();
137
138        // Find blobs that are neither live nor actively referenced.
139        let to_remove: Vec<FunctionHash> = self
140            .blob_store
141            .keys()
142            .filter(|hash| !live_hashes.contains(hash) && !self.active_references.contains(hash))
143            .copied()
144            .collect();
145
146        let removed = to_remove.len();
147        for hash in to_remove {
148            self.blob_store.remove(&hash);
149        }
150        removed
151    }
152
153    /// Get reload history.
154    pub fn history(&self) -> &[ReloadEvent] {
155        &self.update_history
156    }
157
158    /// Add a path to watch for changes.
159    pub fn watch_path(&mut self, path: PathBuf) {
160        self.watch_paths.push(path);
161    }
162
163    /// Get all watched paths.
164    pub fn watched_paths(&self) -> &[PathBuf] {
165        &self.watch_paths
166    }
167
168    /// Compute which functions need recompilation given a set of changed source files.
169    ///
170    /// For now, returns all functions (conservative). Future: use source map to narrow down.
171    pub fn compute_affected_functions(&self, _changed_files: &[PathBuf]) -> Vec<String> {
172        self.current_mappings.keys().cloned().collect()
173    }
174}
175
176impl Default for HotReloader {
177    fn default() -> Self {
178        Self::new()
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use crate::bytecode::{FunctionBlob, FunctionHash};
186
187    /// Create a minimal test blob with a given name and unique hash.
188    fn make_blob(name: &str, seed: u8) -> FunctionBlob {
189        let mut hash_bytes = [0u8; 32];
190        hash_bytes[0] = seed;
191        FunctionBlob {
192            content_hash: FunctionHash(hash_bytes),
193            name: name.to_string(),
194            arity: 0,
195            param_names: Vec::new(),
196            locals_count: 0,
197            is_closure: false,
198            captures_count: 0,
199            is_async: false,
200            ref_params: Vec::new(),
201            ref_mutates: Vec::new(),
202            mutable_captures: Vec::new(),
203            frame_descriptor: None,
204            instructions: Vec::new(),
205            constants: Vec::new(),
206            strings: Vec::new(),
207            required_permissions: Default::default(),
208            dependencies: Vec::new(),
209            callee_names: Vec::new(),
210            type_schemas: Vec::new(),
211            source_map: Vec::new(),
212            foreign_dependencies: Vec::new(),
213        }
214    }
215
216    #[test]
217    fn test_register_and_lookup() {
218        let mut hr = HotReloader::new();
219        let blob = make_blob("foo", 1);
220        let hash = blob.content_hash;
221        hr.register_function("foo".into(), blob);
222
223        assert_eq!(hr.current_hash("foo"), Some(&hash));
224        assert!(hr.get_blob(&hash).is_some());
225        assert_eq!(hr.get_blob(&hash).unwrap().name, "foo");
226    }
227
228    #[test]
229    fn test_apply_patches_updates_mapping() {
230        let mut hr = HotReloader::new();
231        let blob_v1 = make_blob("bar", 10);
232        hr.register_function("bar".into(), blob_v1);
233
234        let blob_v2 = make_blob("bar", 20);
235        let new_hash = blob_v2.content_hash;
236        let result = hr.apply_patches(vec![FunctionPatch {
237            function_name: "bar".into(),
238            old_hash: Some(FunctionHash([10; 32])),
239            new_blob: blob_v2,
240        }]);
241
242        assert_eq!(result.functions_updated, vec!["bar"]);
243        assert!(result.functions_unchanged.is_empty());
244        assert_eq!(hr.current_hash("bar"), Some(&new_hash));
245        assert_eq!(hr.history().len(), 1);
246    }
247
248    #[test]
249    fn test_apply_patches_unchanged_when_same_hash() {
250        let mut hr = HotReloader::new();
251        let blob = make_blob("baz", 5);
252        hr.register_function("baz".into(), blob.clone());
253
254        let result = hr.apply_patches(vec![FunctionPatch {
255            function_name: "baz".into(),
256            old_hash: None,
257            new_blob: blob,
258        }]);
259
260        assert!(result.functions_updated.is_empty());
261        assert_eq!(result.functions_unchanged, vec!["baz"]);
262    }
263
264    #[test]
265    fn test_gc_removes_unreferenced_old_blobs() {
266        let mut hr = HotReloader::new();
267        let blob_v1 = make_blob("fn1", 1);
268        let old_hash = blob_v1.content_hash;
269        hr.register_function("fn1".into(), blob_v1);
270
271        // Update to v2
272        let blob_v2 = make_blob("fn1", 2);
273        hr.apply_patches(vec![FunctionPatch {
274            function_name: "fn1".into(),
275            old_hash: Some(old_hash),
276            new_blob: blob_v2,
277        }]);
278
279        // Old blob still in store before GC
280        assert!(hr.get_blob(&old_hash).is_some());
281
282        // GC should remove the old blob (no active references)
283        let removed = hr.gc();
284        assert_eq!(removed, 1);
285        assert!(hr.get_blob(&old_hash).is_none());
286    }
287
288    #[test]
289    fn test_gc_retains_actively_referenced_blobs() {
290        let mut hr = HotReloader::new();
291        let blob_v1 = make_blob("fn2", 10);
292        let old_hash = blob_v1.content_hash;
293        hr.register_function("fn2".into(), blob_v1);
294
295        // Mark old blob as actively referenced (in-flight frame)
296        hr.add_active_reference(old_hash);
297
298        // Update to v2
299        let blob_v2 = make_blob("fn2", 20);
300        hr.apply_patches(vec![FunctionPatch {
301            function_name: "fn2".into(),
302            old_hash: Some(old_hash),
303            new_blob: blob_v2,
304        }]);
305
306        // GC should NOT remove the old blob (active reference)
307        let removed = hr.gc();
308        assert_eq!(removed, 0);
309        assert!(hr.get_blob(&old_hash).is_some());
310
311        // Release the reference
312        hr.remove_active_reference(old_hash);
313
314        // Now GC should clean it up
315        let removed = hr.gc();
316        assert_eq!(removed, 1);
317        assert!(hr.get_blob(&old_hash).is_none());
318    }
319
320    #[test]
321    fn test_watch_paths() {
322        let mut hr = HotReloader::new();
323        assert!(hr.watched_paths().is_empty());
324
325        hr.watch_path(PathBuf::from("/src/main.shape"));
326        hr.watch_path(PathBuf::from("/src/lib.shape"));
327
328        assert_eq!(hr.watched_paths().len(), 2);
329    }
330
331    #[test]
332    fn test_compute_affected_functions_conservative() {
333        let mut hr = HotReloader::new();
334        hr.register_function("a".into(), make_blob("a", 1));
335        hr.register_function("b".into(), make_blob("b", 2));
336        hr.register_function("c".into(), make_blob("c", 3));
337
338        let affected = hr.compute_affected_functions(&[PathBuf::from("/src/something.shape")]);
339        // Conservative: returns all functions
340        assert_eq!(affected.len(), 3);
341    }
342
343    #[test]
344    fn test_old_blobs_retained_count() {
345        let mut hr = HotReloader::new();
346        let blob_v1 = make_blob("f", 1);
347        let old_hash = blob_v1.content_hash;
348        hr.register_function("f".into(), blob_v1);
349
350        // Mark old version as active
351        hr.add_active_reference(old_hash);
352
353        let blob_v2 = make_blob("f", 2);
354        let result = hr.apply_patches(vec![FunctionPatch {
355            function_name: "f".into(),
356            old_hash: Some(old_hash),
357            new_blob: blob_v2,
358        }]);
359
360        assert_eq!(result.old_blobs_retained, 1);
361    }
362}