Skip to main content

ghostlink_core/
watcher.rs

1//! Dynamic system-profile watcher for hot-plug hardware detection.
2//!
3//! `SystemProfileWatcher` monitors OS-specific sources for hardware changes
4//! (GPU hot-plug, driver install, memory change) and publishes updates via a
5//! `broadcast` channel so downstream subsystems can re-tune.
6
7use std::sync::Arc;
8use std::sync::Mutex;
9use std::time::Duration;
10use tokio::sync::broadcast;
11
12use crate::system_profile::SystemProfile;
13
14/// Events published by the watcher.
15#[derive(Clone, Debug)]
16pub enum ProfileChange {
17    /// A new or updated system profile is available.
18    Updated(Arc<SystemProfile>),
19    /// A specific hardware change was observed.
20    HardwareChange { description: String },
21}
22
23/// Watcher for dynamic hardware changes.
24///
25/// Uses polling on all platforms (inotify/FSEvents/ReadDirectoryChanges are
26/// future work) with a configurable interval.
27pub struct SystemProfileWatcher {
28    /// Last-known system profile.
29    last_profile: Arc<Mutex<Option<Arc<SystemProfile>>>>,
30    /// Publisher for change events.
31    tx: broadcast::Sender<ProfileChange>,
32    /// Polling interval.
33    poll_interval: Duration,
34}
35
36impl SystemProfileWatcher {
37    /// Create a new watcher with the given polling interval.
38    ///
39    /// The initial system profile is detected immediately.
40    pub fn new(poll_interval: Duration) -> Self {
41        let initial = Arc::new(SystemProfile::detect());
42        let (tx, _rx) = broadcast::channel(16);
43
44        Self {
45            last_profile: Arc::new(Mutex::new(Some(initial))),
46            tx,
47            poll_interval,
48        }
49    }
50
51    /// Get a receiver for change events.
52    ///
53    /// The receiver will immediately receive the current profile snapshot.
54    pub fn subscribe(&self) -> broadcast::Receiver<ProfileChange> {
55        let rx = self.tx.subscribe();
56        // Send best-effort current snapshot to this subscriber.
57        // If the buffer is full the subscriber can still poll.
58        if let Some(profile) = self.current_profile() {
59            let _ = self.tx.send(ProfileChange::Updated(profile));
60        }
61        rx
62    }
63
64    /// Get the most recently detected profile (blocking, fast).
65    pub fn current_profile(&self) -> Option<Arc<SystemProfile>> {
66        self.last_profile.lock().ok().and_then(|g| g.clone())
67    }
68
69    /// Perform a single poll cycle: re-detect and publish if changed.
70    pub fn poll_once(&self) {
71        let fresh = Arc::new(SystemProfile::detect_fast());
72        let changed = {
73            let guard = self.last_profile.lock().ok();
74            match guard.and_then(|g| g.as_ref().map(|old| profiles_differ(old, &fresh))) {
75                Some(true) | None => true,
76                Some(false) => false,
77            }
78        };
79
80        if changed {
81            if let Ok(mut guard) = self.last_profile.lock() {
82                *guard = Some(fresh.clone());
83            }
84            let _ = self.tx.send(ProfileChange::Updated(fresh));
85        }
86    }
87
88    /// Start the background polling loop. Runs until the returned handle is dropped.
89    pub fn start_background(self: &Arc<Self>) -> tokio::task::JoinHandle<()> {
90        let this = self.clone();
91        tokio::spawn(async move {
92            loop {
93                tokio::time::sleep(this.poll_interval).await;
94                this.poll_once();
95            }
96        })
97    }
98}
99
100/// Quick structural comparison to avoid full profile hashing.
101fn profiles_differ(a: &SystemProfile, b: &SystemProfile) -> bool {
102    if a.gpus.len() != b.gpus.len() {
103        return true;
104    }
105    for (ga, gb) in a.gpus.iter().zip(b.gpus.iter()) {
106        if ga.name != gb.name || (ga.vram_gb - gb.vram_gb).abs() > 0.1 || ga.backend != gb.backend {
107            return true;
108        }
109    }
110    if a.npus.len() != b.npus.len() {
111        return true;
112    }
113    if a.cpu.logical_cores != b.cpu.logical_cores {
114        return true;
115    }
116    if (a.memory.total_gb - b.memory.total_gb).abs() > 0.5 {
117        return true;
118    }
119    false
120}
121
122// ---------------------------------------------------------------------------
123// Convenience: one-shot watch-and-reload
124// ---------------------------------------------------------------------------
125
126/// Run a single watch cycle: detect changes and return a new `SystemProfile`
127/// if hardware has changed since `last`.
128pub fn check_for_changes(last: &SystemProfile) -> Option<SystemProfile> {
129    let current = SystemProfile::detect_fast();
130    if profiles_differ(last, &current) {
131        Some(current)
132    } else {
133        None
134    }
135}
136
137// ---------------------------------------------------------------------------
138// Tests
139// ---------------------------------------------------------------------------
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn watcher_starts_with_valid_profile() {
147        let watcher = SystemProfileWatcher::new(Duration::from_secs(60));
148        let profile = watcher.current_profile();
149        assert!(profile.is_some());
150        assert!(!profile.unwrap().gpus.is_empty());
151    }
152
153    #[test]
154    fn watcher_subscribe_receives_initial() {
155        let watcher = SystemProfileWatcher::new(Duration::from_secs(60));
156        let mut rx = watcher.subscribe();
157        // Should have the initial profile via try_recv
158        let event = rx.try_recv();
159        assert!(
160            event.is_ok(),
161            "should have initial event: {:?}",
162            event.err()
163        );
164        match event.unwrap() {
165            ProfileChange::Updated(_) => {} // expected
166            _ => panic!("expected Updated event"),
167        }
168    }
169
170    #[test]
171    fn poll_once_does_not_panic() {
172        let watcher = SystemProfileWatcher::new(Duration::from_secs(60));
173        watcher.poll_once(); // Should not crash
174    }
175
176    #[test]
177    fn check_for_changes_returns_none_when_unchanged() {
178        let profile = SystemProfile::detect_fast();
179        let result = check_for_changes(&profile);
180        // May return Some if HW detection is non-deterministic,
181        // but it must not panic
182        let _ = result;
183    }
184
185    #[test]
186    fn profiles_differ_detects_gpu_count_change() {
187        let a = SystemProfile::detect_fast();
188        let mut b = a.clone();
189        b.gpus.clear();
190        assert!(profiles_differ(&a, &b));
191    }
192}