Skip to main content

oximedia_plugin/
hot_reload.rs

1//! Hot-reload plugin management.
2//!
3//! Enables plugins to be updated at runtime without restarting the host process.
4//! File changes are detected via FNV-1a content hashing; actual shared-library
5//! unloading and reloading is delegated to the dynamic-loading layer (not performed
6//! here — this module focuses on lifecycle management and change detection).
7//!
8//! # Design
9//!
10//! - [`HotReloadManager`] owns the set of loaded plugins and their metadata.
11//! - [`WatchEntry`] associates a plugin ID with its source path and last known hash.
12//! - `check_for_changes` compares hashes of in-memory bytes against stored hashes;
13//!   callers supply the content (e.g. freshly read file bytes) for comparison.
14//! - [`PluginLifecycle`] is a trait for plugin objects that want to be notified
15//!   about load/unload/reload events.
16//! - [`GracefulReload`] wraps a drain-then-reload sequence with a configurable timeout.
17
18use crate::error::{PluginError, PluginResult};
19use crate::version_resolver::SemVer;
20use std::collections::HashMap;
21use std::time::Instant;
22
23// ── compute_hash ──────────────────────────────────────────────────────────────
24
25/// Compute a 64-bit FNV-1a hash of `data`.
26///
27/// FNV-1a is a non-cryptographic hash suitable for change detection.
28/// See <http://www.isthe.com/chongo/tech/comp/fnv/>.
29pub fn compute_hash(data: &[u8]) -> u64 {
30    const FNV_OFFSET_BASIS: u64 = 14_695_981_039_346_656_037;
31    const FNV_PRIME: u64 = 1_099_511_628_211;
32
33    let mut hash = FNV_OFFSET_BASIS;
34    for &byte in data {
35        hash ^= u64::from(byte);
36        hash = hash.wrapping_mul(FNV_PRIME);
37    }
38    hash
39}
40
41/// Compute a 64-bit FNV-1a hash of the contents of the file at `path`.
42///
43/// Returns an `io::Error` if the file cannot be read.
44pub fn compute_hash_file(path: &std::path::Path) -> std::io::Result<u64> {
45    let data = std::fs::read(path)?;
46    Ok(compute_hash(&data))
47}
48
49// ── ReloadPolicy ─────────────────────────────────────────────────────────────
50
51/// When should the [`HotReloadManager`] attempt to reload a changed plugin?
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum ReloadPolicy {
54    /// Reload as soon as a content change is detected.
55    OnChange,
56    /// Reload only when explicitly triggered by a signal or API call.
57    OnSignal,
58    /// Reload on a fixed schedule (millisecond interval).
59    Scheduled { interval_ms: u64 },
60    /// Hot-reload is disabled; plugins are only loaded once.
61    Disabled,
62}
63
64// ── PluginVersion ─────────────────────────────────────────────────────────────
65
66/// Metadata about a currently loaded plugin version.
67#[derive(Debug, Clone)]
68pub struct PluginVersion {
69    /// Plugin identifier.
70    pub id: String,
71    /// Parsed semantic version.
72    pub version: SemVer,
73    /// FNV-1a hash of the plugin binary (for change detection).
74    pub hash: u64,
75    /// When this version was loaded.
76    pub loaded_at: Instant,
77}
78
79impl PluginVersion {
80    /// Create a new `PluginVersion`.
81    pub fn new(id: impl Into<String>, version: SemVer, hash: u64) -> Self {
82        Self {
83            id: id.into(),
84            version,
85            hash,
86            loaded_at: Instant::now(),
87        }
88    }
89}
90
91// ── WatchEntry ────────────────────────────────────────────────────────────────
92
93/// Tracks a single plugin file for change detection.
94#[derive(Debug, Clone)]
95pub struct WatchEntry {
96    /// Plugin identifier.
97    pub plugin_id: String,
98    /// Path (or logical name) of the plugin source.
99    pub path: String,
100    /// FNV-1a hash of the last-seen content.
101    pub last_hash: u64,
102}
103
104impl WatchEntry {
105    /// Construct a new watch entry for a plugin with the given initial hash.
106    pub fn new(plugin_id: impl Into<String>, path: impl Into<String>, initial_hash: u64) -> Self {
107        Self {
108            plugin_id: plugin_id.into(),
109            path: path.into(),
110            last_hash: initial_hash,
111        }
112    }
113}
114
115// ── PluginLifecycle ───────────────────────────────────────────────────────────
116
117/// Lifecycle hooks for hot-reloadable plugins.
118///
119/// Implementors receive notifications at key lifecycle moments so they can
120/// flush state, release resources, or migrate data across versions.
121pub trait PluginLifecycle {
122    /// Called immediately after the plugin is loaded for the first time.
123    fn on_load(&mut self);
124
125    /// Called immediately before the plugin is unloaded.
126    fn on_unload(&mut self);
127
128    /// Called after the plugin has been reloaded.
129    ///
130    /// `old_version` carries the metadata of the previous instance so the
131    /// plugin can decide whether to perform a data migration.
132    fn on_reload(&mut self, old_version: &PluginVersion);
133}
134
135// ── HotReloadManager ─────────────────────────────────────────────────────────
136
137/// Manages the hot-reload lifecycle for a set of plugins.
138///
139/// # Simulation note
140///
141/// This implementation does not perform actual dynamic library loading; it
142/// manages the *metadata* and *change-detection* layer.  The actual dlopen /
143/// dlclose operations are performed by the `loader` module (feature-gated on
144/// `dynamic-loading`).
145pub struct HotReloadManager {
146    /// Currently loaded plugins by ID.
147    pub loaded_plugins: HashMap<String, PluginVersion>,
148    /// Active reload policy.
149    pub policy: ReloadPolicy,
150    /// File watchers for change detection.
151    pub watchers: Vec<WatchEntry>,
152    /// Timestamp of the last scheduled check (used with `Scheduled` policy).
153    last_check: Instant,
154}
155
156impl HotReloadManager {
157    /// Create a new manager with the given policy and no loaded plugins.
158    pub fn new(policy: ReloadPolicy) -> Self {
159        Self {
160            loaded_plugins: HashMap::new(),
161            policy,
162            watchers: Vec::new(),
163            last_check: Instant::now(),
164        }
165    }
166
167    /// Register a plugin as loaded with the given metadata.
168    pub fn register_loaded(&mut self, version: PluginVersion) {
169        self.loaded_plugins.insert(version.id.clone(), version);
170    }
171
172    /// Add a watcher for a plugin path.
173    ///
174    /// `initial_content` should be the current bytes of the plugin binary so
175    /// that the baseline hash is computed correctly.
176    pub fn watch(
177        &mut self,
178        plugin_id: impl Into<String>,
179        path: impl Into<String>,
180        initial_content: &[u8],
181    ) {
182        let plugin_id = plugin_id.into();
183        let path = path.into();
184        let hash = compute_hash(initial_content);
185        self.watchers.push(WatchEntry::new(plugin_id, path, hash));
186    }
187
188    /// Check for changes by comparing `current_content` (indexed by plugin_id)
189    /// against stored hashes.
190    ///
191    /// Returns the IDs of plugins whose content has changed.  The internal
192    /// `last_hash` values are **not** updated here; call `update_hash` after
193    /// a successful reload.
194    pub fn check_for_changes(&self, current_content: &HashMap<String, Vec<u8>>) -> Vec<String> {
195        self.watchers
196            .iter()
197            .filter_map(|w| {
198                let content = current_content.get(&w.plugin_id)?;
199                let new_hash = compute_hash(content);
200                if new_hash != w.last_hash {
201                    Some(w.plugin_id.clone())
202                } else {
203                    None
204                }
205            })
206            .collect()
207    }
208
209    /// Update the stored hash for a plugin after a successful reload.
210    pub fn update_hash(&mut self, plugin_id: &str, new_content: &[u8]) {
211        let new_hash = compute_hash(new_content);
212        for w in &mut self.watchers {
213            if w.plugin_id == plugin_id {
214                w.last_hash = new_hash;
215                return;
216            }
217        }
218    }
219
220    /// Simulate reloading a plugin (metadata update only — no actual dlopen).
221    ///
222    /// Updates the `loaded_at` timestamp and version hash in the manager's
223    /// internal state.
224    ///
225    /// # Errors
226    ///
227    /// Returns [`PluginError::NotFound`] if the plugin ID is not currently loaded.
228    pub fn reload_plugin(
229        &mut self,
230        plugin_id: &str,
231        new_version: PluginVersion,
232    ) -> PluginResult<()> {
233        if !self.loaded_plugins.contains_key(plugin_id) {
234            return Err(PluginError::NotFound(plugin_id.to_string()));
235        }
236        self.loaded_plugins
237            .insert(plugin_id.to_string(), new_version);
238        Ok(())
239    }
240
241    /// Unload a plugin, removing it from the manager.
242    ///
243    /// # Errors
244    ///
245    /// Returns [`PluginError::NotFound`] if the plugin is not loaded.
246    pub fn unload_plugin(&mut self, plugin_id: &str) -> PluginResult<PluginVersion> {
247        self.loaded_plugins
248            .remove(plugin_id)
249            .ok_or_else(|| PluginError::NotFound(plugin_id.to_string()))
250    }
251
252    /// Check whether a scheduled reload is due.
253    ///
254    /// Returns `true` only when the policy is [`ReloadPolicy::Scheduled`] and
255    /// the interval has elapsed.  Resets the internal timer on a `true` return.
256    pub fn is_scheduled_reload_due(&mut self) -> bool {
257        if let ReloadPolicy::Scheduled { interval_ms } = &self.policy {
258            let elapsed = self.last_check.elapsed().as_millis() as u64;
259            if elapsed >= *interval_ms {
260                self.last_check = Instant::now();
261                return true;
262            }
263        }
264        false
265    }
266
267    /// Return `true` if the policy allows automatic reloading.
268    pub fn auto_reload_enabled(&self) -> bool {
269        !matches!(self.policy, ReloadPolicy::Disabled)
270    }
271}
272
273// ── GracefulReload ────────────────────────────────────────────────────────────
274
275/// Performs a drain-then-reload sequence with a configurable drain timeout.
276///
277/// In a production system, "draining" means waiting for in-flight requests
278/// to finish before swapping the plugin.  This struct models that timeout
279/// and provides a helper that delegates the actual reload to
280/// [`HotReloadManager::reload_plugin`].
281pub struct GracefulReload {
282    /// Maximum time (ms) to wait for in-flight operations to complete.
283    pub drain_timeout_ms: u64,
284}
285
286impl GracefulReload {
287    /// Create a new graceful-reload helper.
288    pub fn new(drain_timeout_ms: u64) -> Self {
289        Self { drain_timeout_ms }
290    }
291
292    /// Drain active operations (simulated) then reload the plugin.
293    ///
294    /// This implementation uses a simple busy-wait simulation:
295    /// - If `drain_timeout_ms` is zero the drain is considered instant.
296    /// - Otherwise the caller is assumed to have already drained; the method
297    ///   just records the elapsed time and proceeds.
298    ///
299    /// # Errors
300    ///
301    /// Propagates any error from [`HotReloadManager::reload_plugin`].
302    pub fn drain_and_reload(
303        &self,
304        plugin_id: &str,
305        manager: &mut HotReloadManager,
306        new_version: PluginVersion,
307    ) -> PluginResult<()> {
308        // In a real implementation this would:
309        //  1. Signal the plugin to stop accepting new work.
310        //  2. Wait up to `drain_timeout_ms` for in-flight operations to finish.
311        //  3. Force-terminate remaining operations if the deadline is exceeded.
312        //
313        // Here we simulate a successful drain (no actual I/O / threading).
314        let _start = Instant::now();
315        manager.reload_plugin(plugin_id, new_version)
316    }
317}
318
319// ── Tests ─────────────────────────────────────────────────────────────────────
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    fn make_version(id: &str, major: u32, minor: u32, patch: u32) -> PluginVersion {
326        PluginVersion::new(id, SemVer::new(major, minor, patch), 0)
327    }
328
329    // 1. compute_hash is deterministic
330    #[test]
331    fn test_hash_deterministic() {
332        let h1 = compute_hash(b"hello");
333        let h2 = compute_hash(b"hello");
334        assert_eq!(h1, h2);
335    }
336
337    // 2. compute_hash differs for different content
338    #[test]
339    fn test_hash_distinct() {
340        assert_ne!(compute_hash(b"foo"), compute_hash(b"bar"));
341    }
342
343    // 3. compute_hash of empty slice is the FNV offset basis
344    #[test]
345    fn test_hash_empty() {
346        let h = compute_hash(&[]);
347        assert_eq!(h, 14_695_981_039_346_656_037);
348    }
349
350    // 4. HotReloadManager::new has no loaded plugins
351    #[test]
352    fn test_manager_new_empty() {
353        let m = HotReloadManager::new(ReloadPolicy::OnChange);
354        assert!(m.loaded_plugins.is_empty());
355        assert!(m.watchers.is_empty());
356    }
357
358    // 5. register_loaded stores a plugin
359    #[test]
360    fn test_register_loaded() {
361        let mut m = HotReloadManager::new(ReloadPolicy::Disabled);
362        m.register_loaded(make_version("codec-a", 1, 0, 0));
363        assert!(m.loaded_plugins.contains_key("codec-a"));
364    }
365
366    // 6. watch creates a watcher with correct hash
367    #[test]
368    fn test_watch_hash() {
369        let mut m = HotReloadManager::new(ReloadPolicy::OnChange);
370        let content = b"binary data";
371        m.watch("plug", "/lib/plug.so", content);
372        assert_eq!(m.watchers.len(), 1);
373        assert_eq!(m.watchers[0].last_hash, compute_hash(content));
374    }
375
376    // 7. check_for_changes: no change → empty list
377    #[test]
378    fn test_no_changes() {
379        let mut m = HotReloadManager::new(ReloadPolicy::OnChange);
380        let content = b"same content";
381        m.watch("p", "/lib/p.so", content);
382
383        let mut current = HashMap::new();
384        current.insert("p".to_string(), content.to_vec());
385
386        let changed = m.check_for_changes(&current);
387        assert!(changed.is_empty());
388    }
389
390    // 8. check_for_changes: changed content → plugin ID returned
391    #[test]
392    fn test_change_detected() {
393        let mut m = HotReloadManager::new(ReloadPolicy::OnChange);
394        m.watch("p", "/lib/p.so", b"v1");
395
396        let mut current = HashMap::new();
397        current.insert("p".to_string(), b"v2".to_vec());
398
399        let changed = m.check_for_changes(&current);
400        assert_eq!(changed, vec!["p".to_string()]);
401    }
402
403    // 9. update_hash clears the changed flag
404    #[test]
405    fn test_update_hash_clears_change() {
406        let mut m = HotReloadManager::new(ReloadPolicy::OnChange);
407        m.watch("p", "/lib/p.so", b"v1");
408        m.update_hash("p", b"v2");
409
410        let mut current = HashMap::new();
411        current.insert("p".to_string(), b"v2".to_vec());
412
413        let changed = m.check_for_changes(&current);
414        assert!(changed.is_empty());
415    }
416
417    // 10. reload_plugin updates the loaded version
418    #[test]
419    fn test_reload_plugin() {
420        let mut m = HotReloadManager::new(ReloadPolicy::OnChange);
421        m.register_loaded(make_version("p", 1, 0, 0));
422
423        let new_v = make_version("p", 1, 1, 0);
424        m.reload_plugin("p", new_v).expect("reload");
425
426        assert_eq!(m.loaded_plugins["p"].version, SemVer::new(1, 1, 0));
427    }
428
429    // 11. reload_plugin on unknown ID → NotFound
430    #[test]
431    fn test_reload_unknown() {
432        let mut m = HotReloadManager::new(ReloadPolicy::OnChange);
433        let err = m.reload_plugin("ghost", make_version("ghost", 1, 0, 0));
434        assert!(matches!(err, Err(PluginError::NotFound(_))));
435    }
436
437    // 12. unload_plugin removes the plugin
438    #[test]
439    fn test_unload_plugin() {
440        let mut m = HotReloadManager::new(ReloadPolicy::Disabled);
441        m.register_loaded(make_version("p", 1, 0, 0));
442        m.unload_plugin("p").expect("unload");
443        assert!(!m.loaded_plugins.contains_key("p"));
444    }
445
446    // 13. unload_plugin on unknown ID → NotFound
447    #[test]
448    fn test_unload_unknown() {
449        let mut m = HotReloadManager::new(ReloadPolicy::Disabled);
450        assert!(matches!(
451            m.unload_plugin("ghost"),
452            Err(PluginError::NotFound(_))
453        ));
454    }
455
456    // 14. auto_reload_enabled: Disabled → false
457    #[test]
458    fn test_auto_reload_disabled() {
459        let m = HotReloadManager::new(ReloadPolicy::Disabled);
460        assert!(!m.auto_reload_enabled());
461    }
462
463    // 15. auto_reload_enabled: OnChange → true
464    #[test]
465    fn test_auto_reload_on_change() {
466        let m = HotReloadManager::new(ReloadPolicy::OnChange);
467        assert!(m.auto_reload_enabled());
468    }
469
470    // 16. is_scheduled_reload_due: not due with large interval
471    #[test]
472    fn test_scheduled_not_due() {
473        let mut m = HotReloadManager::new(ReloadPolicy::Scheduled {
474            interval_ms: 1_000_000,
475        });
476        assert!(!m.is_scheduled_reload_due());
477    }
478
479    // 17. GracefulReload::drain_and_reload succeeds
480    #[test]
481    fn test_graceful_reload_ok() {
482        let mut m = HotReloadManager::new(ReloadPolicy::OnChange);
483        m.register_loaded(make_version("p", 1, 0, 0));
484
485        let gr = GracefulReload::new(100);
486        let new_v = make_version("p", 1, 2, 0);
487        gr.drain_and_reload("p", &mut m, new_v)
488            .expect("graceful reload");
489
490        assert_eq!(m.loaded_plugins["p"].version, SemVer::new(1, 2, 0));
491    }
492
493    // 18. GracefulReload on unknown plugin propagates NotFound
494    #[test]
495    fn test_graceful_reload_not_found() {
496        let mut m = HotReloadManager::new(ReloadPolicy::OnChange);
497        let gr = GracefulReload::new(0);
498        let err = gr.drain_and_reload("ghost", &mut m, make_version("ghost", 1, 0, 0));
499        assert!(matches!(err, Err(PluginError::NotFound(_))));
500    }
501
502    // 19. WatchEntry stores correct path
503    #[test]
504    fn test_watch_entry_path() {
505        let w = WatchEntry::new("plug", "/some/path.so", 0xABCD);
506        assert_eq!(w.plugin_id, "plug");
507        assert_eq!(w.path, "/some/path.so");
508        assert_eq!(w.last_hash, 0xABCD);
509    }
510
511    // 20. Multiple watchers, only changed ones returned
512    #[test]
513    fn test_multiple_watchers_selective() {
514        let mut m = HotReloadManager::new(ReloadPolicy::OnChange);
515        m.watch("a", "/lib/a.so", b"v1a");
516        m.watch("b", "/lib/b.so", b"v1b");
517
518        let mut current = HashMap::new();
519        current.insert("a".to_string(), b"v2a".to_vec()); // changed
520        current.insert("b".to_string(), b"v1b".to_vec()); // unchanged
521
522        let changed = m.check_for_changes(&current);
523        assert_eq!(changed.len(), 1);
524        assert_eq!(changed[0], "a");
525    }
526}