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            instructions: Vec::new(),
204            constants: Vec::new(),
205            strings: Vec::new(),
206            required_permissions: Default::default(),
207            dependencies: Vec::new(),
208            callee_names: Vec::new(),
209            type_schemas: Vec::new(),
210            source_map: Vec::new(),
211            foreign_dependencies: Vec::new(),
212        }
213    }
214
215    #[test]
216    fn test_register_and_lookup() {
217        let mut hr = HotReloader::new();
218        let blob = make_blob("foo", 1);
219        let hash = blob.content_hash;
220        hr.register_function("foo".into(), blob);
221
222        assert_eq!(hr.current_hash("foo"), Some(&hash));
223        assert!(hr.get_blob(&hash).is_some());
224        assert_eq!(hr.get_blob(&hash).unwrap().name, "foo");
225    }
226
227    #[test]
228    fn test_apply_patches_updates_mapping() {
229        let mut hr = HotReloader::new();
230        let blob_v1 = make_blob("bar", 10);
231        hr.register_function("bar".into(), blob_v1);
232
233        let blob_v2 = make_blob("bar", 20);
234        let new_hash = blob_v2.content_hash;
235        let result = hr.apply_patches(vec![FunctionPatch {
236            function_name: "bar".into(),
237            old_hash: Some(FunctionHash([10; 32])),
238            new_blob: blob_v2,
239        }]);
240
241        assert_eq!(result.functions_updated, vec!["bar"]);
242        assert!(result.functions_unchanged.is_empty());
243        assert_eq!(hr.current_hash("bar"), Some(&new_hash));
244        assert_eq!(hr.history().len(), 1);
245    }
246
247    #[test]
248    fn test_apply_patches_unchanged_when_same_hash() {
249        let mut hr = HotReloader::new();
250        let blob = make_blob("baz", 5);
251        hr.register_function("baz".into(), blob.clone());
252
253        let result = hr.apply_patches(vec![FunctionPatch {
254            function_name: "baz".into(),
255            old_hash: None,
256            new_blob: blob,
257        }]);
258
259        assert!(result.functions_updated.is_empty());
260        assert_eq!(result.functions_unchanged, vec!["baz"]);
261    }
262
263    #[test]
264    fn test_gc_removes_unreferenced_old_blobs() {
265        let mut hr = HotReloader::new();
266        let blob_v1 = make_blob("fn1", 1);
267        let old_hash = blob_v1.content_hash;
268        hr.register_function("fn1".into(), blob_v1);
269
270        // Update to v2
271        let blob_v2 = make_blob("fn1", 2);
272        hr.apply_patches(vec![FunctionPatch {
273            function_name: "fn1".into(),
274            old_hash: Some(old_hash),
275            new_blob: blob_v2,
276        }]);
277
278        // Old blob still in store before GC
279        assert!(hr.get_blob(&old_hash).is_some());
280
281        // GC should remove the old blob (no active references)
282        let removed = hr.gc();
283        assert_eq!(removed, 1);
284        assert!(hr.get_blob(&old_hash).is_none());
285    }
286
287    #[test]
288    fn test_gc_retains_actively_referenced_blobs() {
289        let mut hr = HotReloader::new();
290        let blob_v1 = make_blob("fn2", 10);
291        let old_hash = blob_v1.content_hash;
292        hr.register_function("fn2".into(), blob_v1);
293
294        // Mark old blob as actively referenced (in-flight frame)
295        hr.add_active_reference(old_hash);
296
297        // Update to v2
298        let blob_v2 = make_blob("fn2", 20);
299        hr.apply_patches(vec![FunctionPatch {
300            function_name: "fn2".into(),
301            old_hash: Some(old_hash),
302            new_blob: blob_v2,
303        }]);
304
305        // GC should NOT remove the old blob (active reference)
306        let removed = hr.gc();
307        assert_eq!(removed, 0);
308        assert!(hr.get_blob(&old_hash).is_some());
309
310        // Release the reference
311        hr.remove_active_reference(old_hash);
312
313        // Now GC should clean it up
314        let removed = hr.gc();
315        assert_eq!(removed, 1);
316        assert!(hr.get_blob(&old_hash).is_none());
317    }
318
319    #[test]
320    fn test_watch_paths() {
321        let mut hr = HotReloader::new();
322        assert!(hr.watched_paths().is_empty());
323
324        hr.watch_path(PathBuf::from("/src/main.shape"));
325        hr.watch_path(PathBuf::from("/src/lib.shape"));
326
327        assert_eq!(hr.watched_paths().len(), 2);
328    }
329
330    #[test]
331    fn test_compute_affected_functions_conservative() {
332        let mut hr = HotReloader::new();
333        hr.register_function("a".into(), make_blob("a", 1));
334        hr.register_function("b".into(), make_blob("b", 2));
335        hr.register_function("c".into(), make_blob("c", 3));
336
337        let affected = hr.compute_affected_functions(&[PathBuf::from("/src/something.shape")]);
338        // Conservative: returns all functions
339        assert_eq!(affected.len(), 3);
340    }
341
342    #[test]
343    fn test_old_blobs_retained_count() {
344        let mut hr = HotReloader::new();
345        let blob_v1 = make_blob("f", 1);
346        let old_hash = blob_v1.content_hash;
347        hr.register_function("f".into(), blob_v1);
348
349        // Mark old version as active
350        hr.add_active_reference(old_hash);
351
352        let blob_v2 = make_blob("f", 2);
353        let result = hr.apply_patches(vec![FunctionPatch {
354            function_name: "f".into(),
355            old_hash: Some(old_hash),
356            new_blob: blob_v2,
357        }]);
358
359        assert_eq!(result.old_blobs_retained, 1);
360    }
361}