duende_observe/
monitor.rs

1//! Real-time daemon monitoring via /proc filesystem parsing.
2//!
3//! # Toyota Way: Visual Management (目で見る管理)
4//! Make daemon health visible at a glance through direct observation.
5//!
6//! # Implementation
7//!
8//! On Linux, parses /proc filesystem:
9//! - `/proc/{pid}/stat` - CPU time, state, threads
10//! - `/proc/{pid}/statm` - Memory pages
11//! - `/proc/{pid}/io` - I/O bytes (requires permissions)
12//! - `/proc/meminfo` - Total system memory for percentage
13
14use std::collections::VecDeque;
15use std::time::Instant;
16
17use crate::error::{ObserveError, Result};
18
19/// Real-time daemon monitor using /proc filesystem collectors.
20///
21/// Provides metrics collection with:
22/// - CPU/memory/disk/network usage
23/// - Ring buffer for historical data
24/// - Zero allocations after warmup
25pub struct DaemonMonitor {
26    /// Ring buffer capacity.
27    capacity: usize,
28    /// Snapshot history.
29    history: VecDeque<DaemonSnapshot>,
30    /// Previous CPU measurement for delta calculation.
31    prev_cpu: Option<CpuMeasurement>,
32    /// Page size in bytes (cached from sysconf).
33    #[cfg(target_os = "linux")]
34    page_size: u64,
35    /// Total system memory in bytes (cached).
36    #[cfg(target_os = "linux")]
37    total_memory: u64,
38}
39
40/// Internal struct for CPU delta calculation.
41#[derive(Clone)]
42struct CpuMeasurement {
43    /// Process user time in clock ticks.
44    utime: u64,
45    /// Process system time in clock ticks.
46    stime: u64,
47    /// Wall clock time of measurement.
48    wall_time: Instant,
49}
50
51impl DaemonMonitor {
52    /// Creates a new daemon monitor with given history capacity.
53    #[must_use]
54    pub fn new(capacity: usize) -> Self {
55        #[cfg(target_os = "linux")]
56        let (page_size, total_memory) = {
57            let page_size = Self::get_page_size();
58            let total_memory = Self::read_total_memory().unwrap_or(0);
59            (page_size, total_memory)
60        };
61
62        Self {
63            capacity,
64            history: VecDeque::with_capacity(capacity),
65            prev_cpu: None,
66            #[cfg(target_os = "linux")]
67            page_size,
68            #[cfg(target_os = "linux")]
69            total_memory,
70        }
71    }
72
73    /// Collects metrics for a specific daemon.
74    ///
75    /// # Errors
76    /// Returns an error if the process doesn't exist or collection fails.
77    pub fn collect(&mut self, pid: u32) -> Result<DaemonSnapshot> {
78        #[cfg(target_os = "linux")]
79        let snapshot = self.collect_linux(pid)?;
80
81        #[cfg(not(target_os = "linux"))]
82        let snapshot = self.collect_fallback(pid)?;
83
84        // Store in ring buffer
85        if self.history.len() >= self.capacity {
86            self.history.pop_front();
87        }
88        self.history.push_back(snapshot.clone());
89
90        Ok(snapshot)
91    }
92
93    /// Linux-specific collection via /proc filesystem.
94    #[cfg(target_os = "linux")]
95    fn collect_linux(&mut self, pid: u32) -> Result<DaemonSnapshot> {
96        let now = Instant::now();
97
98        // Parse /proc/{pid}/stat for CPU, state, threads
99        let stat = self.parse_proc_stat(pid)?;
100
101        // Calculate CPU percentage from delta
102        let cpu_percent = self.calculate_cpu_percent(&stat, now);
103
104        // Update previous measurement
105        self.prev_cpu = Some(CpuMeasurement {
106            utime: stat.utime,
107            stime: stat.stime,
108            wall_time: now,
109        });
110
111        // Parse /proc/{pid}/statm for memory
112        let memory_bytes = self.parse_proc_statm(pid)?;
113        let memory_percent = if self.total_memory > 0 {
114            (memory_bytes as f64 / self.total_memory as f64) * 100.0
115        } else {
116            0.0
117        };
118
119        // Parse /proc/{pid}/io for I/O stats (may fail without permissions)
120        let (io_read_bytes, io_write_bytes) = self.parse_proc_io(pid).unwrap_or((0, 0));
121
122        Ok(DaemonSnapshot {
123            timestamp: now,
124            pid,
125            cpu_percent,
126            memory_bytes,
127            memory_percent,
128            threads: stat.num_threads,
129            state: stat.state,
130            io_read_bytes,
131            io_write_bytes,
132            gpu_utilization: None,
133            gpu_memory: None,
134        })
135    }
136
137    /// Fallback implementation for non-Linux systems.
138    #[cfg(not(target_os = "linux"))]
139    fn collect_fallback(&mut self, pid: u32) -> Result<DaemonSnapshot> {
140        // On non-Linux, we can't parse /proc
141        // Return a basic snapshot indicating the process state is unknown
142        Ok(DaemonSnapshot {
143            timestamp: Instant::now(),
144            pid,
145            cpu_percent: 0.0,
146            memory_bytes: 0,
147            memory_percent: 0.0,
148            threads: 0,
149            state: ProcessState::Unknown,
150            io_read_bytes: 0,
151            io_write_bytes: 0,
152            gpu_utilization: None,
153            gpu_memory: None,
154        })
155    }
156
157    /// Get system page size.
158    #[cfg(target_os = "linux")]
159    fn get_page_size() -> u64 {
160        // SAFETY: sysconf is safe to call with _SC_PAGESIZE
161        #[allow(unsafe_code)]
162        unsafe {
163            libc::sysconf(libc::_SC_PAGESIZE) as u64
164        }
165    }
166
167    /// Read total system memory from /proc/meminfo.
168    #[cfg(target_os = "linux")]
169    fn read_total_memory() -> Result<u64> {
170        let content = std::fs::read_to_string("/proc/meminfo")?;
171        for line in content.lines() {
172            if line.starts_with("MemTotal:") {
173                // Format: "MemTotal:       16384000 kB"
174                let parts: Vec<&str> = line.split_whitespace().collect();
175                if parts.len() >= 2
176                    && let Ok(kb) = parts[1].parse::<u64>()
177                {
178                    return Ok(kb * 1024); // Convert kB to bytes
179                }
180            }
181        }
182        Err(ObserveError::monitor(
183            "failed to parse MemTotal from /proc/meminfo",
184        ))
185    }
186
187    /// Parse /proc/{pid}/stat for CPU and process info.
188    #[cfg(target_os = "linux")]
189    #[allow(clippy::unused_self)]
190    fn parse_proc_stat(&self, pid: u32) -> Result<ProcStat> {
191        let path = format!("/proc/{}/stat", pid);
192        let content = std::fs::read_to_string(&path).map_err(|e| {
193            if e.kind() == std::io::ErrorKind::NotFound {
194                ObserveError::monitor(format!("process {} not found", pid))
195            } else {
196                ObserveError::Io(e)
197            }
198        })?;
199
200        Self::parse_stat_content(&content)
201    }
202
203    /// Parse the content of /proc/{pid}/stat.
204    ///
205    /// Format: pid (comm) state ppid pgrp session tty_nr tpgid flags minflt cminflt majflt cmajflt
206    ///         utime stime cutime cstime priority nice num_threads itrealvalue starttime vsize rss ...
207    #[cfg(target_os = "linux")]
208    fn parse_stat_content(content: &str) -> Result<ProcStat> {
209        // Handle process names with spaces/parentheses by finding the last ')'
210        let comm_end = content
211            .rfind(')')
212            .ok_or_else(|| ObserveError::monitor("malformed /proc/stat: no closing paren"))?;
213
214        let after_comm = &content[comm_end + 2..]; // Skip ") "
215        let fields: Vec<&str> = after_comm.split_whitespace().collect();
216
217        if fields.len() < 20 {
218            return Err(ObserveError::monitor(format!(
219                "malformed /proc/stat: expected 20+ fields, got {}",
220                fields.len()
221            )));
222        }
223
224        // Field indices (0-indexed after comm):
225        // 0: state, 11: utime, 12: stime, 17: num_threads
226        let state = match fields[0].chars().next() {
227            Some('R') => ProcessState::Running,
228            Some('S') => ProcessState::Sleeping,
229            Some('D') => ProcessState::DiskWait,
230            Some('Z') => ProcessState::Zombie,
231            Some('T' | 't') => ProcessState::Stopped,
232            _ => ProcessState::Unknown,
233        };
234
235        let utime = fields[11]
236            .parse()
237            .map_err(|_| ObserveError::monitor("failed to parse utime"))?;
238        let stime = fields[12]
239            .parse()
240            .map_err(|_| ObserveError::monitor("failed to parse stime"))?;
241        let num_threads = fields[17]
242            .parse()
243            .map_err(|_| ObserveError::monitor("failed to parse num_threads"))?;
244
245        Ok(ProcStat {
246            state,
247            utime,
248            stime,
249            num_threads,
250        })
251    }
252
253    /// Calculate CPU percentage from time delta.
254    #[cfg(target_os = "linux")]
255    fn calculate_cpu_percent(&self, stat: &ProcStat, now: Instant) -> f64 {
256        let Some(prev) = &self.prev_cpu else {
257            return 0.0; // No previous measurement, can't calculate delta
258        };
259
260        let elapsed = now.duration_since(prev.wall_time);
261        if elapsed.as_secs_f64() < 0.001 {
262            return 0.0; // Too small interval
263        }
264
265        // CPU ticks used in this interval
266        let total_ticks_now = stat.utime + stat.stime;
267        let total_ticks_prev = prev.utime + prev.stime;
268
269        if total_ticks_now < total_ticks_prev {
270            return 0.0; // Counter wrapped or process restarted
271        }
272
273        let ticks_used = total_ticks_now - total_ticks_prev;
274
275        // Convert clock ticks to seconds
276        // SAFETY: sysconf is safe to call with _SC_CLK_TCK
277        #[allow(unsafe_code)]
278        let clk_tck = unsafe { libc::sysconf(libc::_SC_CLK_TCK) } as f64;
279
280        let cpu_seconds = ticks_used as f64 / clk_tck;
281        let cpu_percent = (cpu_seconds / elapsed.as_secs_f64()) * 100.0;
282
283        // Clamp to reasonable range (can exceed 100% on multi-core)
284        cpu_percent.max(0.0)
285    }
286
287    /// Parse /proc/{pid}/statm for memory info.
288    #[cfg(target_os = "linux")]
289    fn parse_proc_statm(&self, pid: u32) -> Result<u64> {
290        let path = format!("/proc/{}/statm", pid);
291        let content = std::fs::read_to_string(&path)?;
292
293        // Format: size resident shared text lib data dt
294        // We want "resident" (index 1) which is RSS in pages
295        let fields: Vec<&str> = content.split_whitespace().collect();
296        if fields.len() < 2 {
297            return Err(ObserveError::monitor("malformed /proc/statm"));
298        }
299
300        let rss_pages: u64 = fields[1]
301            .parse()
302            .map_err(|_| ObserveError::monitor("failed to parse RSS pages"))?;
303
304        Ok(rss_pages * self.page_size)
305    }
306
307    /// Parse /proc/{pid}/io for I/O statistics.
308    #[cfg(target_os = "linux")]
309    #[allow(clippy::unused_self)]
310    fn parse_proc_io(&self, pid: u32) -> Result<(u64, u64)> {
311        let path = format!("/proc/{}/io", pid);
312        let content = std::fs::read_to_string(&path)?;
313
314        let mut read_bytes = 0u64;
315        let mut write_bytes = 0u64;
316
317        for line in content.lines() {
318            if let Some(value) = line.strip_prefix("read_bytes: ") {
319                read_bytes = value.trim().parse().unwrap_or(0);
320            } else if let Some(value) = line.strip_prefix("write_bytes: ") {
321                write_bytes = value.trim().parse().unwrap_or(0);
322            }
323        }
324
325        Ok((read_bytes, write_bytes))
326    }
327
328    /// Returns historical snapshots within the given duration.
329    #[must_use]
330    pub fn history(&self, duration: std::time::Duration) -> Vec<&DaemonSnapshot> {
331        let now = Instant::now();
332        let cutoff = now.checked_sub(duration);
333
334        cutoff.map_or_else(
335            // If duration is larger than time since epoch, return all
336            || self.history.iter().collect(),
337            |cutoff| {
338                self.history
339                    .iter()
340                    .filter(|s| s.timestamp >= cutoff)
341                    .collect()
342            },
343        )
344    }
345
346    /// Returns all historical snapshots.
347    #[must_use]
348    pub fn all_history(&self) -> &VecDeque<DaemonSnapshot> {
349        &self.history
350    }
351
352    /// Clears history.
353    pub fn clear_history(&mut self) {
354        self.history.clear();
355        self.prev_cpu = None;
356    }
357}
358
359impl Default for DaemonMonitor {
360    fn default() -> Self {
361        Self::new(1000) // 1000 samples default
362    }
363}
364
365/// Internal struct for parsed /proc/{pid}/stat.
366#[cfg(target_os = "linux")]
367#[derive(Debug)]
368struct ProcStat {
369    state: ProcessState,
370    utime: u64,
371    stime: u64,
372    num_threads: u32,
373}
374
375/// Snapshot of daemon metrics at a point in time.
376#[derive(Debug, Clone)]
377pub struct DaemonSnapshot {
378    /// Timestamp of collection.
379    pub timestamp: Instant,
380    /// Process ID.
381    pub pid: u32,
382    /// CPU usage percentage.
383    pub cpu_percent: f64,
384    /// Memory usage in bytes (RSS).
385    pub memory_bytes: u64,
386    /// Memory usage percentage.
387    pub memory_percent: f64,
388    /// Thread count.
389    pub threads: u32,
390    /// Process state.
391    pub state: ProcessState,
392    /// I/O bytes read.
393    pub io_read_bytes: u64,
394    /// I/O bytes written.
395    pub io_write_bytes: u64,
396    /// GPU utilization (if available).
397    pub gpu_utilization: Option<f64>,
398    /// GPU memory used (if available).
399    pub gpu_memory: Option<u64>,
400}
401
402/// Process state.
403#[derive(Debug, Clone, Copy, PartialEq, Eq)]
404pub enum ProcessState {
405    /// Running.
406    Running,
407    /// Sleeping.
408    Sleeping,
409    /// Waiting for disk.
410    DiskWait,
411    /// Zombie.
412    Zombie,
413    /// Stopped.
414    Stopped,
415    /// Unknown.
416    Unknown,
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422
423    #[test]
424    fn test_monitor_creation() {
425        let monitor = DaemonMonitor::new(100);
426        assert!(monitor.all_history().is_empty());
427    }
428
429    #[test]
430    fn test_monitor_default() {
431        let monitor = DaemonMonitor::default();
432        assert_eq!(monitor.capacity, 1000);
433    }
434
435    #[test]
436    fn test_ring_buffer_capacity() {
437        let mut monitor = DaemonMonitor::new(3);
438        // Use PID 1 (init) which always exists on Linux
439        for _ in 0..5 {
440            let _ = monitor.collect(1);
441        }
442        assert_eq!(monitor.all_history().len(), 3);
443    }
444
445    #[test]
446    fn test_clear_history() {
447        let mut monitor = DaemonMonitor::new(100);
448        let _ = monitor.collect(1);
449        monitor.clear_history();
450        assert!(monitor.all_history().is_empty());
451        assert!(monitor.prev_cpu.is_none());
452    }
453
454    #[cfg(target_os = "linux")]
455    mod linux_tests {
456        use super::*;
457        use std::process;
458
459        #[test]
460        fn test_collect_self() {
461            let mut monitor = DaemonMonitor::new(100);
462            let pid = process::id();
463            let result = monitor.collect(pid);
464            assert!(result.is_ok(), "Failed to collect self: {:?}", result.err());
465
466            let snapshot = result.unwrap();
467            assert_eq!(snapshot.pid, pid);
468            assert!(snapshot.memory_bytes > 0, "Memory should be non-zero");
469            assert!(snapshot.threads >= 1, "Should have at least 1 thread");
470            // Process can be in various states: Running, Sleeping, DiskWait (during I/O)
471            assert!(
472                matches!(
473                    snapshot.state,
474                    ProcessState::Running | ProcessState::Sleeping | ProcessState::DiskWait
475                ),
476                "Process should be running, sleeping, or in disk wait, got: {:?}",
477                snapshot.state
478            );
479        }
480
481        #[test]
482        fn test_collect_init() {
483            let mut monitor = DaemonMonitor::new(100);
484            let result = monitor.collect(1);
485            assert!(result.is_ok(), "Failed to collect init: {:?}", result.err());
486
487            let snapshot = result.unwrap();
488            assert_eq!(snapshot.pid, 1);
489        }
490
491        #[test]
492        fn test_collect_nonexistent_process() {
493            let mut monitor = DaemonMonitor::new(100);
494            // Use a very high PID that's unlikely to exist
495            let result = monitor.collect(4_000_000_000);
496            assert!(result.is_err());
497            let err = result.unwrap_err().to_string();
498            assert!(err.contains("not found") || err.contains("No such file"));
499        }
500
501        #[test]
502        fn test_cpu_percent_requires_two_samples() {
503            let mut monitor = DaemonMonitor::new(100);
504            let pid = process::id();
505
506            // First sample should have 0% CPU (no previous measurement)
507            let snap1 = monitor.collect(pid).unwrap();
508            assert_eq!(snap1.cpu_percent, 0.0);
509
510            // Do some work
511            let mut sum = 0u64;
512            for i in 0..100_000 {
513                sum = sum.wrapping_add(i);
514            }
515            std::hint::black_box(sum);
516
517            // Wait a bit for measurable time delta
518            std::thread::sleep(std::time::Duration::from_millis(10));
519
520            // Second sample should have a CPU percentage
521            let snap2 = monitor.collect(pid).unwrap();
522            // CPU percent can still be 0 if interval is too short or no CPU used
523            // Just verify it's a valid value
524            assert!(snap2.cpu_percent >= 0.0);
525        }
526
527        #[test]
528        fn test_parse_stat_content_simple() {
529            let content = "1234 (test) S 1 1234 1234 0 -1 4194304 100 0 0 0 50 25 0 0 20 0 5 0 1000 1000000 100 18446744073709551615";
530            let stat = DaemonMonitor::parse_stat_content(content).unwrap();
531            assert_eq!(stat.state, ProcessState::Sleeping);
532            assert_eq!(stat.utime, 50);
533            assert_eq!(stat.stime, 25);
534            assert_eq!(stat.num_threads, 5);
535        }
536
537        #[test]
538        fn test_parse_stat_content_with_spaces_in_name() {
539            // Process name with spaces and parentheses
540            let content = "1234 (test (process)) R 1 1234 1234 0 -1 4194304 100 0 0 0 100 50 0 0 20 0 10 0 1000 1000000 100 18446744073709551615";
541            let stat = DaemonMonitor::parse_stat_content(content).unwrap();
542            assert_eq!(stat.state, ProcessState::Running);
543            assert_eq!(stat.utime, 100);
544            assert_eq!(stat.stime, 50);
545            assert_eq!(stat.num_threads, 10);
546        }
547
548        #[test]
549        fn test_parse_stat_all_states() {
550            let test_cases = [
551                (
552                    "1 (t) R 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0 0 1 0 0 0 0 0",
553                    ProcessState::Running,
554                ),
555                (
556                    "1 (t) S 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0 0 1 0 0 0 0 0",
557                    ProcessState::Sleeping,
558                ),
559                (
560                    "1 (t) D 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0 0 1 0 0 0 0 0",
561                    ProcessState::DiskWait,
562                ),
563                (
564                    "1 (t) Z 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0 0 1 0 0 0 0 0",
565                    ProcessState::Zombie,
566                ),
567                (
568                    "1 (t) T 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0 0 1 0 0 0 0 0",
569                    ProcessState::Stopped,
570                ),
571                (
572                    "1 (t) t 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0 0 1 0 0 0 0 0",
573                    ProcessState::Stopped,
574                ),
575                (
576                    "1 (t) X 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0 0 1 0 0 0 0 0",
577                    ProcessState::Unknown,
578                ),
579            ];
580
581            for (content, expected_state) in test_cases {
582                let stat = DaemonMonitor::parse_stat_content(content).unwrap();
583                assert_eq!(
584                    stat.state, expected_state,
585                    "Failed for content: {}",
586                    content
587                );
588            }
589        }
590
591        #[test]
592        fn test_parse_stat_malformed_no_paren() {
593            let content = "1234 test S 1";
594            let result = DaemonMonitor::parse_stat_content(content);
595            assert!(result.is_err());
596            assert!(result.unwrap_err().to_string().contains("no closing paren"));
597        }
598
599        #[test]
600        fn test_parse_stat_malformed_too_few_fields() {
601            let content = "1234 (test) S 1 2 3";
602            let result = DaemonMonitor::parse_stat_content(content);
603            assert!(result.is_err());
604            assert!(result.unwrap_err().to_string().contains("expected 20+"));
605        }
606
607        #[test]
608        fn test_memory_collection() {
609            let mut monitor = DaemonMonitor::new(100);
610            let pid = process::id();
611            let snapshot = monitor.collect(pid).unwrap();
612
613            // Our process should use at least 1MB of memory
614            assert!(
615                snapshot.memory_bytes >= 1024 * 1024,
616                "Expected at least 1MB, got {} bytes",
617                snapshot.memory_bytes
618            );
619
620            // Memory percent should be between 0 and 100
621            assert!(snapshot.memory_percent >= 0.0);
622            assert!(snapshot.memory_percent <= 100.0);
623        }
624
625        #[test]
626        fn test_page_size() {
627            let page_size = DaemonMonitor::get_page_size();
628            // Page size should be 4KB or larger
629            assert!(page_size >= 4096);
630            // Page size should be a power of 2
631            assert!(page_size.is_power_of_two());
632        }
633
634        #[test]
635        fn test_total_memory() {
636            let total = DaemonMonitor::read_total_memory().unwrap();
637            // System should have at least 128MB
638            assert!(
639                total >= 128 * 1024 * 1024,
640                "Expected at least 128MB, got {} bytes",
641                total
642            );
643        }
644
645        #[test]
646        fn test_history_filtering() {
647            let mut monitor = DaemonMonitor::new(100);
648            let pid = process::id();
649
650            // Collect a sample
651            monitor.collect(pid).unwrap();
652            std::thread::sleep(std::time::Duration::from_millis(10));
653            monitor.collect(pid).unwrap();
654
655            // History with long duration should include both
656            let long_history = monitor.history(std::time::Duration::from_secs(60));
657            assert_eq!(long_history.len(), 2);
658
659            // History with zero duration should include none (or recent ones)
660            let short_history = monitor.history(std::time::Duration::from_nanos(1));
661            // Recent samples might still be included due to timing
662            assert!(short_history.len() <= 2);
663        }
664    }
665
666    // ==================== Popperian Falsification Tests ====================
667    // These tests attempt to DISPROVE the correctness of our implementation
668
669    #[cfg(target_os = "linux")]
670    mod falsification_tests {
671        use super::*;
672
673        /// F001: Falsify that parse_stat_content handles edge cases
674        #[test]
675        fn f001_parse_stat_empty_input() {
676            let result = DaemonMonitor::parse_stat_content("");
677            assert!(result.is_err(), "Empty input should fail parsing");
678        }
679
680        /// F002: Falsify that memory values are reasonable
681        #[test]
682        fn f002_memory_not_absurdly_large() {
683            let mut monitor = DaemonMonitor::new(100);
684            let pid = std::process::id();
685            let snapshot = monitor.collect(pid).unwrap();
686
687            // A single process shouldn't use more than total system memory
688            assert!(
689                snapshot.memory_bytes <= monitor.total_memory,
690                "Process memory {} exceeds total memory {}",
691                snapshot.memory_bytes,
692                monitor.total_memory
693            );
694        }
695
696        /// F003: Falsify that CPU percentage stays in bounds
697        #[test]
698        fn f003_cpu_percent_reasonable_bounds() {
699            let mut monitor = DaemonMonitor::new(100);
700            let pid = std::process::id();
701
702            // Collect twice to get CPU delta
703            monitor.collect(pid).unwrap();
704            std::thread::sleep(std::time::Duration::from_millis(50));
705            let snapshot = monitor.collect(pid).unwrap();
706
707            // CPU can exceed 100% on multi-core but shouldn't be negative
708            assert!(
709                snapshot.cpu_percent >= 0.0,
710                "CPU percent should not be negative: {}",
711                snapshot.cpu_percent
712            );
713            // Sanity check: shouldn't be millions of percent
714            assert!(
715                snapshot.cpu_percent < 10000.0,
716                "CPU percent unreasonably high: {}",
717                snapshot.cpu_percent
718            );
719        }
720
721        /// F004: Falsify that threads count is valid
722        #[test]
723        fn f004_threads_at_least_one() {
724            let mut monitor = DaemonMonitor::new(100);
725            let pid = std::process::id();
726            let snapshot = monitor.collect(pid).unwrap();
727
728            assert!(
729                snapshot.threads >= 1,
730                "Running process must have at least 1 thread"
731            );
732        }
733
734        /// F005: Falsify that ring buffer respects capacity
735        #[test]
736        fn f005_ring_buffer_never_exceeds_capacity() {
737            let capacity = 5;
738            let mut monitor = DaemonMonitor::new(capacity);
739
740            for _ in 0..100 {
741                let _ = monitor.collect(1);
742            }
743
744            assert!(
745                monitor.all_history().len() <= capacity,
746                "Ring buffer exceeded capacity: {} > {}",
747                monitor.all_history().len(),
748                capacity
749            );
750        }
751
752        /// F006: Falsify that timestamps are monotonically increasing
753        #[test]
754        fn f006_timestamps_monotonic() {
755            let mut monitor = DaemonMonitor::new(100);
756            let pid = std::process::id();
757
758            for _ in 0..10 {
759                monitor.collect(pid).unwrap();
760            }
761
762            let history = monitor.all_history();
763            for window in history.iter().collect::<Vec<_>>().windows(2) {
764                assert!(
765                    window[1].timestamp >= window[0].timestamp,
766                    "Timestamps should be monotonically increasing"
767                );
768            }
769        }
770
771        /// F007: Falsify that PID in snapshot matches requested PID
772        #[test]
773        fn f007_pid_matches_request() {
774            let mut monitor = DaemonMonitor::new(100);
775            let pid = std::process::id();
776            let snapshot = monitor.collect(pid).unwrap();
777
778            assert_eq!(snapshot.pid, pid, "Snapshot PID should match requested PID");
779        }
780
781        /// F008: Falsify memory percent calculation
782        #[test]
783        fn f008_memory_percent_calculation_valid() {
784            let mut monitor = DaemonMonitor::new(100);
785            let pid = std::process::id();
786            let snapshot = monitor.collect(pid).unwrap();
787
788            // Verify the percent is calculated correctly
789            if monitor.total_memory > 0 {
790                let expected_percent =
791                    (snapshot.memory_bytes as f64 / monitor.total_memory as f64) * 100.0;
792                let diff = (snapshot.memory_percent - expected_percent).abs();
793                assert!(
794                    diff < 0.001,
795                    "Memory percent calculation mismatch: {} vs {}",
796                    snapshot.memory_percent,
797                    expected_percent
798                );
799            }
800        }
801    }
802}