Skip to main content

par_term/status_bar/
system_monitor.rs

1//! System resource monitor for the status bar.
2//!
3//! Polls CPU, memory, and network usage on a background thread using `sysinfo`.
4//! Data is shared via `Arc<parking_lot::Mutex<...>>` for lock-free reads from
5//! the render thread.
6
7use std::time::Instant;
8
9// ============================================================================
10// Shared types (always compiled)
11// ============================================================================
12
13/// Snapshot of system resource usage.
14#[derive(Debug, Clone, Default)]
15pub struct SystemMonitorData {
16    /// Global CPU usage percentage (0.0 - 100.0)
17    pub cpu_usage: f32,
18    /// Memory currently in use (bytes)
19    pub memory_used: u64,
20    /// Total physical memory (bytes)
21    pub memory_total: u64,
22    /// Network receive rate (bytes/sec)
23    pub network_rx_rate: u64,
24    /// Network transmit rate (bytes/sec)
25    pub network_tx_rate: u64,
26    /// When this data was last updated
27    pub last_update: Option<Instant>,
28}
29
30// ============================================================================
31// Full implementation (feature enabled)
32// ============================================================================
33
34#[cfg(feature = "system-monitor")]
35mod inner {
36    use std::sync::Arc;
37    use std::sync::atomic::{AtomicBool, Ordering};
38    use std::thread::JoinHandle;
39    use std::time::{Duration, Instant};
40
41    use parking_lot::Mutex;
42
43    use super::SystemMonitorData;
44
45    /// Background system resource monitor.
46    ///
47    /// Spawns a polling thread that periodically refreshes CPU, memory, and
48    /// network statistics via `sysinfo`.
49    pub struct SystemMonitor {
50        data: Arc<Mutex<SystemMonitorData>>,
51        running: Arc<AtomicBool>,
52        thread: Mutex<Option<JoinHandle<()>>>,
53    }
54
55    impl SystemMonitor {
56        /// Create a new (stopped) system monitor.
57        pub fn new() -> Self {
58            Self {
59                data: Arc::new(Mutex::new(SystemMonitorData::default())),
60                running: Arc::new(AtomicBool::new(false)),
61                thread: Mutex::new(None),
62            }
63        }
64
65        /// Start the polling thread.
66        ///
67        /// If the monitor is already running, this is a no-op.
68        pub fn start(&self, poll_interval_secs: f32) {
69            if self
70                .running
71                .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
72                .is_err()
73            {
74                return;
75            }
76
77            let data = Arc::clone(&self.data);
78            let running = Arc::clone(&self.running);
79            let interval = Duration::from_secs_f32(poll_interval_secs.max(0.5));
80
81            let handle = std::thread::Builder::new()
82                .name("status-bar-sysmon".to_string())
83                .spawn(move || {
84                    use sysinfo::{CpuRefreshKind, MemoryRefreshKind, RefreshKind, System};
85
86                    let mut sys = System::new_with_specifics(
87                        RefreshKind::nothing()
88                            .with_cpu(CpuRefreshKind::everything())
89                            .with_memory(MemoryRefreshKind::everything()),
90                    );
91                    let mut networks = sysinfo::Networks::new_with_refreshed_list();
92
93                    // First CPU poll is always 0% — need two samples.
94                    sys.refresh_cpu_all();
95                    std::thread::sleep(Duration::from_millis(200));
96
97                    let mut prev_rx: u64 = 0;
98                    let mut prev_tx: u64 = 0;
99                    let mut first_net = true;
100
101                    while running.load(Ordering::SeqCst) {
102                        sys.refresh_cpu_all();
103                        sys.refresh_memory();
104                        networks.refresh(true);
105
106                        // Network totals
107                        let (mut total_rx, mut total_tx) = (0u64, 0u64);
108                        for (_name, net) in networks.iter() {
109                            total_rx = total_rx.saturating_add(net.total_received());
110                            total_tx = total_tx.saturating_add(net.total_transmitted());
111                        }
112
113                        let (rx_rate, tx_rate) = if first_net {
114                            first_net = false;
115                            (0, 0)
116                        } else {
117                            let secs = interval.as_secs_f64();
118                            let rx_delta = total_rx.saturating_sub(prev_rx);
119                            let tx_delta = total_tx.saturating_sub(prev_tx);
120                            (
121                                (rx_delta as f64 / secs) as u64,
122                                (tx_delta as f64 / secs) as u64,
123                            )
124                        };
125                        prev_rx = total_rx;
126                        prev_tx = total_tx;
127
128                        {
129                            let mut d = data.lock();
130                            d.cpu_usage = sys.global_cpu_usage();
131                            d.memory_used = sys.used_memory();
132                            d.memory_total = sys.total_memory();
133                            d.network_rx_rate = rx_rate;
134                            d.network_tx_rate = tx_rate;
135                            d.last_update = Some(Instant::now());
136                        }
137
138                        // Sleep in short increments so stop() returns quickly
139                        let deadline = Instant::now() + interval;
140                        while Instant::now() < deadline && running.load(Ordering::Relaxed) {
141                            std::thread::sleep(Duration::from_millis(50));
142                        }
143                    }
144                });
145
146            match handle {
147                Ok(h) => *self.thread.lock() = Some(h),
148                Err(e) => {
149                    // Thread spawn failed (e.g. OS out of resources); reset the
150                    // running flag so start() can be retried and degrade gracefully
151                    // without crashing the terminal session.
152                    self.running.store(false, Ordering::SeqCst);
153                    crate::debug_error!("SESSION_LOGGER", "failed to spawn sysmon thread: {:?}", e);
154                }
155            }
156        }
157
158        /// Signal the polling thread to stop without waiting for it to finish.
159        pub fn signal_stop(&self) {
160            self.running.store(false, Ordering::SeqCst);
161        }
162
163        /// Stop the polling thread and wait for it to finish.
164        pub fn stop(&self) {
165            self.signal_stop();
166            if let Some(handle) = self.thread.lock().take() {
167                let _ = handle.join();
168            }
169        }
170
171        /// Whether the polling thread is currently running.
172        pub fn is_running(&self) -> bool {
173            self.running.load(Ordering::SeqCst)
174        }
175
176        /// Get a clone of the current data snapshot.
177        pub fn data(&self) -> SystemMonitorData {
178            self.data.lock().clone()
179        }
180    }
181
182    impl Default for SystemMonitor {
183        fn default() -> Self {
184            Self::new()
185        }
186    }
187
188    impl Drop for SystemMonitor {
189        fn drop(&mut self) {
190            self.stop();
191        }
192    }
193}
194
195#[cfg(feature = "system-monitor")]
196pub use inner::SystemMonitor;
197
198// ============================================================================
199// Stub implementation (feature disabled)
200// ============================================================================
201
202#[cfg(not(feature = "system-monitor"))]
203mod inner {
204    use super::SystemMonitorData;
205
206    /// Stub system resource monitor (sysinfo feature disabled).
207    ///
208    /// Provides the same public API as the full implementation but all methods
209    /// are no-ops. This allows callers in `StatusBarUI` to compile without
210    /// changes when the `system-monitor` feature is disabled.
211    pub struct SystemMonitor;
212
213    impl SystemMonitor {
214        /// Create a new (stopped) system monitor stub.
215        pub fn new() -> Self {
216            Self
217        }
218
219        /// No-op — the monitor is never started.
220        pub fn start(&self, _poll_interval_secs: f32) {}
221
222        /// No-op — nothing to signal.
223        pub fn signal_stop(&self) {}
224
225        /// No-op — nothing to stop.
226        pub fn stop(&self) {}
227
228        /// Always returns `false` (never running).
229        pub fn is_running(&self) -> bool {
230            false
231        }
232
233        /// Returns a default (all-zero) data snapshot.
234        pub fn data(&self) -> SystemMonitorData {
235            SystemMonitorData::default()
236        }
237    }
238
239    impl Default for SystemMonitor {
240        fn default() -> Self {
241            Self::new()
242        }
243    }
244}
245
246#[cfg(not(feature = "system-monitor"))]
247pub use inner::SystemMonitor;
248
249// ============================================================================
250// Formatting helpers
251// ============================================================================
252
253/// Format bytes-per-second into a fixed-width human-readable string.
254///
255/// Output is always 10 characters wide (e.g. `"  1.0 KB/s"`) so the
256/// status bar doesn't jump around when values change.
257pub fn format_bytes_per_sec(bytes: u64) -> String {
258    const KB: u64 = 1024;
259    const MB: u64 = 1024 * 1024;
260    const GB: u64 = 1024 * 1024 * 1024;
261
262    if bytes >= GB {
263        format!("{:>5.1} GB/s", bytes as f64 / GB as f64)
264    } else if bytes >= MB {
265        format!("{:>5.1} MB/s", bytes as f64 / MB as f64)
266    } else if bytes >= KB {
267        format!("{:>5.1} KB/s", bytes as f64 / KB as f64)
268    } else {
269        // Extra space before "B" so width matches "KB", "MB", "GB"
270        format!("{:>5}  B/s", bytes)
271    }
272}
273
274/// Format memory usage (used / total) into a human-readable string.
275///
276/// Each side is fixed-width (7 chars, e.g. `"  4.0 GB"`) so the status
277/// bar doesn't jump when values change.
278pub fn format_memory(used: u64, total: u64) -> String {
279    fn human(bytes: u64) -> String {
280        const KB: u64 = 1024;
281        const MB: u64 = 1024 * 1024;
282        const GB: u64 = 1024 * 1024 * 1024;
283
284        if bytes >= GB {
285            format!("{:>5.1} GB", bytes as f64 / GB as f64)
286        } else if bytes >= MB {
287            format!("{:>5.1} MB", bytes as f64 / MB as f64)
288        } else if bytes >= KB {
289            format!("{:>5.1} KB", bytes as f64 / KB as f64)
290        } else {
291            format!("{:>5}  B", bytes)
292        }
293    }
294
295    format!("{} / {}", human(used), human(total))
296}
297
298// ============================================================================
299// Tests
300// ============================================================================
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn test_system_monitor_data_default() {
308        let d = SystemMonitorData::default();
309        assert_eq!(d.cpu_usage, 0.0);
310        assert_eq!(d.memory_used, 0);
311        assert_eq!(d.memory_total, 0);
312        assert_eq!(d.network_rx_rate, 0);
313        assert_eq!(d.network_tx_rate, 0);
314        assert!(d.last_update.is_none());
315    }
316
317    #[test]
318    fn test_format_bytes_per_sec() {
319        assert_eq!(format_bytes_per_sec(0), "    0  B/s");
320        assert_eq!(format_bytes_per_sec(512), "  512  B/s");
321        assert_eq!(format_bytes_per_sec(1024), "  1.0 KB/s");
322        assert_eq!(format_bytes_per_sec(1536), "  1.5 KB/s");
323        assert_eq!(format_bytes_per_sec(1_048_576), "  1.0 MB/s");
324        assert_eq!(format_bytes_per_sec(1_073_741_824), "  1.0 GB/s");
325        // All outputs have same width
326        assert_eq!(
327            format_bytes_per_sec(0).len(),
328            format_bytes_per_sec(1024).len()
329        );
330        assert_eq!(
331            format_bytes_per_sec(1024).len(),
332            format_bytes_per_sec(1_048_576).len()
333        );
334    }
335
336    #[test]
337    fn test_format_memory() {
338        assert_eq!(format_memory(0, 0), "    0  B /     0  B");
339        // 1 GB used / 8 GB total
340        assert_eq!(
341            format_memory(1_073_741_824, 8_589_934_592),
342            "  1.0 GB /   8.0 GB"
343        );
344        // 512 MB / 1 GB
345        assert_eq!(
346            format_memory(536_870_912, 1_073_741_824),
347            "512.0 MB /   1.0 GB"
348        );
349    }
350
351    #[cfg(feature = "system-monitor")]
352    #[test]
353    fn test_system_monitor_start_stop() {
354        use std::time::Duration;
355
356        let monitor = SystemMonitor::new();
357        assert!(!monitor.is_running());
358
359        monitor.start(1.0);
360        assert!(monitor.is_running());
361
362        // Wait for the first poll rather than assuming it lands within a fixed
363        // sleep. The initial `sysinfo` refresh enumerates processes and CPUs,
364        // which is markedly slower on Windows and on a loaded CI machine — a
365        // fixed 500ms made this test flaky there.
366        let deadline = std::time::Instant::now() + Duration::from_secs(10);
367        let mut polled = false;
368        while std::time::Instant::now() < deadline {
369            if monitor.data().last_update.is_some() {
370                polled = true;
371                break;
372            }
373            std::thread::sleep(Duration::from_millis(50));
374        }
375        assert!(
376            polled,
377            "system monitor recorded no initial poll within 10s of start()"
378        );
379
380        monitor.stop();
381        assert!(!monitor.is_running());
382    }
383}