1use std::collections::VecDeque;
15use std::time::Instant;
16
17use crate::error::{ObserveError, Result};
18
19pub struct DaemonMonitor {
26 capacity: usize,
28 history: VecDeque<DaemonSnapshot>,
30 prev_cpu: Option<CpuMeasurement>,
32 #[cfg(target_os = "linux")]
34 page_size: u64,
35 #[cfg(target_os = "linux")]
37 total_memory: u64,
38}
39
40#[derive(Clone)]
42struct CpuMeasurement {
43 utime: u64,
45 stime: u64,
47 wall_time: Instant,
49}
50
51impl DaemonMonitor {
52 #[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 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 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 #[cfg(target_os = "linux")]
95 fn collect_linux(&mut self, pid: u32) -> Result<DaemonSnapshot> {
96 let now = Instant::now();
97
98 let stat = self.parse_proc_stat(pid)?;
100
101 let cpu_percent = self.calculate_cpu_percent(&stat, now);
103
104 self.prev_cpu = Some(CpuMeasurement {
106 utime: stat.utime,
107 stime: stat.stime,
108 wall_time: now,
109 });
110
111 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 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 #[cfg(not(target_os = "linux"))]
139 fn collect_fallback(&mut self, pid: u32) -> Result<DaemonSnapshot> {
140 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 #[cfg(target_os = "linux")]
159 fn get_page_size() -> u64 {
160 #[allow(unsafe_code)]
162 unsafe {
163 libc::sysconf(libc::_SC_PAGESIZE) as u64
164 }
165 }
166
167 #[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 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); }
180 }
181 }
182 Err(ObserveError::monitor(
183 "failed to parse MemTotal from /proc/meminfo",
184 ))
185 }
186
187 #[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 #[cfg(target_os = "linux")]
208 fn parse_stat_content(content: &str) -> Result<ProcStat> {
209 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..]; 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 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 #[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; };
259
260 let elapsed = now.duration_since(prev.wall_time);
261 if elapsed.as_secs_f64() < 0.001 {
262 return 0.0; }
264
265 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; }
272
273 let ticks_used = total_ticks_now - total_ticks_prev;
274
275 #[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 cpu_percent.max(0.0)
285 }
286
287 #[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 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 #[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 #[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 || self.history.iter().collect(),
337 |cutoff| {
338 self.history
339 .iter()
340 .filter(|s| s.timestamp >= cutoff)
341 .collect()
342 },
343 )
344 }
345
346 #[must_use]
348 pub fn all_history(&self) -> &VecDeque<DaemonSnapshot> {
349 &self.history
350 }
351
352 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) }
363}
364
365#[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#[derive(Debug, Clone)]
377pub struct DaemonSnapshot {
378 pub timestamp: Instant,
380 pub pid: u32,
382 pub cpu_percent: f64,
384 pub memory_bytes: u64,
386 pub memory_percent: f64,
388 pub threads: u32,
390 pub state: ProcessState,
392 pub io_read_bytes: u64,
394 pub io_write_bytes: u64,
396 pub gpu_utilization: Option<f64>,
398 pub gpu_memory: Option<u64>,
400}
401
402#[derive(Debug, Clone, Copy, PartialEq, Eq)]
404pub enum ProcessState {
405 Running,
407 Sleeping,
409 DiskWait,
411 Zombie,
413 Stopped,
415 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 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 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 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 let snap1 = monitor.collect(pid).unwrap();
508 assert_eq!(snap1.cpu_percent, 0.0);
509
510 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 std::thread::sleep(std::time::Duration::from_millis(10));
519
520 let snap2 = monitor.collect(pid).unwrap();
522 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 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 assert!(
615 snapshot.memory_bytes >= 1024 * 1024,
616 "Expected at least 1MB, got {} bytes",
617 snapshot.memory_bytes
618 );
619
620 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 assert!(page_size >= 4096);
630 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 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 monitor.collect(pid).unwrap();
652 std::thread::sleep(std::time::Duration::from_millis(10));
653 monitor.collect(pid).unwrap();
654
655 let long_history = monitor.history(std::time::Duration::from_secs(60));
657 assert_eq!(long_history.len(), 2);
658
659 let short_history = monitor.history(std::time::Duration::from_nanos(1));
661 assert!(short_history.len() <= 2);
663 }
664 }
665
666 #[cfg(target_os = "linux")]
670 mod falsification_tests {
671 use super::*;
672
673 #[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 #[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 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 #[test]
698 fn f003_cpu_percent_reasonable_bounds() {
699 let mut monitor = DaemonMonitor::new(100);
700 let pid = std::process::id();
701
702 monitor.collect(pid).unwrap();
704 std::thread::sleep(std::time::Duration::from_millis(50));
705 let snapshot = monitor.collect(pid).unwrap();
706
707 assert!(
709 snapshot.cpu_percent >= 0.0,
710 "CPU percent should not be negative: {}",
711 snapshot.cpu_percent
712 );
713 assert!(
715 snapshot.cpu_percent < 10000.0,
716 "CPU percent unreasonably high: {}",
717 snapshot.cpu_percent
718 );
719 }
720
721 #[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 #[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 #[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 #[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 #[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 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}