prock/
lib.rs

1//! Fast, low-overhead CPU statistics for process trees.
2//!
3//! This crate provides multiple approaches for measuring CPU usage:
4//!
5//! 1. **Snapshot-based**: Get raw CPU time at a point in time
6//! 2. **Delta-based**: Track changes between two snapshots
7//! 3. **Cumulative**: Track total CPU from a baseline (good for short-lived processes)
8//!
9//! All approaches use direct syscalls for minimal overhead (~1-5µs per process).
10
11#![deny(unsafe_code)] // Unsafe only in platform-specific modules
12
13mod platform;
14
15use std::collections::HashMap;
16use std::time::{Duration, Instant};
17
18pub use platform::{
19    AllStats, CpuTime, DiskIo, ProcessInfo, build_parent_map, get_all_stats, get_children,
20    get_cpu_time, get_cpu_time_with_children, get_disk_io, get_memory, get_memory_virtual,
21    get_ppid, get_process_comm, get_process_environ, get_process_path, get_start_time, get_tty,
22    list_all_pids, scan_all_processes,
23};
24
25/// CPU usage as a percentage (0.0 - 100.0+ for multi-core).
26pub type CpuPercent = f32;
27
28/// Statistics for a single process.
29#[derive(Debug, Clone, Default)]
30pub struct ProcessStats {
31    pub cpu_time: CpuTime,
32    pub memory_bytes: u64,
33}
34
35/// Statistics for a process tree (process + all descendants).
36#[derive(Debug, Clone, Default)]
37pub struct TreeStats {
38    /// Total CPU time across all processes in tree
39    pub cpu_time: CpuTime,
40    /// Total memory across all processes in tree
41    pub memory_bytes: u64,
42    /// Number of processes in tree
43    pub process_count: u32,
44    /// List of all PIDs in tree
45    pub pids: Vec<i32>,
46}
47
48/// Approach 1: Snapshot-based measurement.
49///
50/// Get CPU time at a point in time. Caller computes deltas.
51/// Lowest overhead, most flexible.
52pub fn snapshot_process(pid: i32) -> Option<ProcessStats> {
53    let cpu_time = get_cpu_time(pid)?;
54    let memory_bytes = platform::get_memory(pid).unwrap_or(0);
55    Some(ProcessStats {
56        cpu_time,
57        memory_bytes,
58    })
59}
60
61/// Snapshot an entire process tree.
62pub fn snapshot_tree(pid: i32) -> Option<TreeStats> {
63    let pids = collect_tree_pids(pid);
64    if pids.is_empty() {
65        return None;
66    }
67
68    let mut total_cpu = CpuTime::default();
69    let mut total_memory = 0u64;
70
71    for &p in &pids {
72        if let Some(cpu) = get_cpu_time(p) {
73            total_cpu = total_cpu + cpu;
74        }
75        total_memory += platform::get_memory(p).unwrap_or(0);
76    }
77
78    Some(TreeStats {
79        cpu_time: total_cpu,
80        memory_bytes: total_memory,
81        process_count: pids.len() as u32,
82        pids,
83    })
84}
85
86/// Approach 2: Delta-based tracker.
87///
88/// Tracks CPU deltas between refresh calls.
89/// Similar to how `top` works.
90#[derive(Debug)]
91pub struct DeltaTracker {
92    /// Previous snapshot per PID
93    snapshots: HashMap<i32, (CpuTime, Instant)>,
94}
95
96impl DeltaTracker {
97    pub fn new() -> Self {
98        Self {
99            snapshots: HashMap::new(),
100        }
101    }
102
103    /// Get CPU percentage for a process since last call.
104    /// First call returns None (establishing baseline).
105    pub fn get_cpu_percent(&mut self, pid: i32) -> Option<CpuPercent> {
106        let now = Instant::now();
107        let current = get_cpu_time(pid)?;
108
109        let result = if let Some((prev_cpu, prev_time)) = self.snapshots.get(&pid) {
110            let elapsed = now.duration_since(*prev_time);
111            if elapsed.as_nanos() == 0 {
112                return Some(0.0);
113            }
114            Some(current.cpu_percent_since(prev_cpu, elapsed))
115        } else {
116            None // First sample, no delta yet
117        };
118
119        self.snapshots.insert(pid, (current, now));
120        result
121    }
122
123    /// Get CPU percentage for a process tree.
124    ///
125    /// Returns partial result with 0% CPU for processes without a baseline.
126    /// Returns None only if the process tree is empty (root doesn't exist).
127    pub fn get_tree_cpu_percent(&mut self, root_pid: i32) -> Option<(CpuPercent, u64, u32)> {
128        let pids = collect_tree_pids(root_pid);
129        if pids.is_empty() {
130            return None;
131        }
132
133        let mut total_percent = 0.0f32;
134        let mut total_memory = 0u64;
135        let mut all_have_baseline = true;
136
137        for &pid in &pids {
138            match self.get_cpu_percent(pid) {
139                Some(pct) => total_percent += pct,
140                None => all_have_baseline = false,
141            }
142            total_memory += platform::get_memory(pid).unwrap_or(0);
143        }
144
145        if all_have_baseline {
146            Some((total_percent, total_memory, pids.len() as u32))
147        } else {
148            // Return partial result with 0% for processes without baseline
149            Some((total_percent, total_memory, pids.len() as u32))
150        }
151    }
152
153    /// Remove stale entries for dead processes.
154    pub fn prune_dead(&mut self) {
155        self.snapshots
156            .retain(|&pid, _| platform::process_exists(pid));
157    }
158}
159
160impl Default for DeltaTracker {
161    fn default() -> Self {
162        Self::new()
163    }
164}
165
166/// Approach 3: Cumulative tracker.
167///
168/// Tracks CPU usage from a fixed baseline (t=0).
169/// Better for catching short-lived processes because the measurement
170/// window grows over time (50ms, 100ms, 150ms, etc.).
171#[derive(Debug)]
172pub struct CumulativeTracker {
173    /// Baseline CPU time per PID (captured at first observation)
174    baselines: HashMap<i32, (CpuTime, Instant)>,
175    /// When tracking started
176    start_time: Instant,
177}
178
179impl CumulativeTracker {
180    pub fn new() -> Self {
181        Self {
182            baselines: HashMap::new(),
183            start_time: Instant::now(),
184        }
185    }
186
187    /// Get CPU percentage for a process since it was first observed.
188    /// Unlike DeltaTracker, this computes delta from the FIRST observation,
189    /// giving a longer measurement window as time progresses.
190    pub fn get_cpu_percent(&mut self, pid: i32) -> Option<CpuPercent> {
191        let now = Instant::now();
192        let current = get_cpu_time(pid)?;
193
194        if let Some((baseline_cpu, baseline_time)) = self.baselines.get(&pid) {
195            let elapsed = now.duration_since(*baseline_time);
196            if elapsed.as_nanos() == 0 {
197                return Some(0.0);
198            }
199            Some(current.cpu_percent_since(baseline_cpu, elapsed))
200        } else {
201            // First observation - store baseline
202            self.baselines.insert(pid, (current, now));
203            Some(0.0) // No CPU yet (just started tracking)
204        }
205    }
206
207    /// Get CPU percentage for a process tree since tracking started.
208    pub fn get_tree_cpu_percent(&mut self, root_pid: i32) -> Option<(CpuPercent, u64, u32)> {
209        let pids = collect_tree_pids(root_pid);
210        if pids.is_empty() {
211            return None;
212        }
213
214        let mut total_percent = 0.0f32;
215        let mut total_memory = 0u64;
216
217        for &pid in &pids {
218            if let Some(pct) = self.get_cpu_percent(pid) {
219                total_percent += pct;
220            }
221            total_memory += platform::get_memory(pid).unwrap_or(0);
222        }
223
224        Some((total_percent, total_memory, pids.len() as u32))
225    }
226
227    /// Reset baselines for all processes (start fresh measurement).
228    pub fn reset(&mut self) {
229        self.baselines.clear();
230        self.start_time = Instant::now();
231    }
232
233    /// Time since tracking started.
234    pub fn elapsed(&self) -> Duration {
235        self.start_time.elapsed()
236    }
237}
238
239impl Default for CumulativeTracker {
240    fn default() -> Self {
241        Self::new()
242    }
243}
244
245/// Collect all PIDs in a process tree (BFS traversal).
246pub fn collect_tree_pids(root_pid: i32) -> Vec<i32> {
247    use std::collections::HashSet;
248
249    let mut seen = HashSet::new();
250    let mut all_pids = Vec::new();
251    let mut queue = vec![root_pid];
252
253    // Check if root exists
254    if !platform::process_exists(root_pid) {
255        return all_pids;
256    }
257
258    while let Some(pid) = queue.pop() {
259        // Use HashSet for O(1) contains check instead of Vec's O(n)
260        if seen.insert(pid) {
261            all_pids.push(pid);
262            queue.extend(get_children(pid));
263        }
264    }
265
266    all_pids
267}
268
269/// Check if `child_pid` is a descendant of `parent_pid` using pre-built map.
270///
271/// Returns true if child_pid == parent_pid or if there's a parent chain
272/// from child_pid to parent_pid. Walks at most 50 levels to avoid infinite loops.
273///
274/// # Arguments
275/// * `child_pid` - The potential descendant process ID
276/// * `parent_pid` - The potential ancestor process ID
277/// * `parent_map` - Map of pid -> parent pid (from `build_parent_map()`)
278///
279/// # Example
280/// ```ignore
281/// let parent_map = prock::build_parent_map();
282/// let is_child = prock::is_descendant_of(child_pid, parent_pid, &parent_map);
283/// ```
284pub fn is_descendant_of<S: std::hash::BuildHasher>(
285    child_pid: i32,
286    parent_pid: i32,
287    parent_map: &HashMap<i32, i32, S>,
288) -> bool {
289    if child_pid == parent_pid {
290        return true;
291    }
292
293    let mut current_pid = child_pid;
294    for _ in 0..50 {
295        if let Some(&ppid) = parent_map.get(&current_pid) {
296            if ppid == parent_pid {
297                return true;
298            }
299            if ppid <= 1 {
300                return false;
301            }
302            current_pid = ppid;
303        } else {
304            return false;
305        }
306    }
307    false
308}
309
310/// Build a HashMap of pid -> children from a full process scan.
311///
312/// This is used with the full scan approach: scan all processes once,
313/// then use this map to quickly find descendants of any process.
314pub fn build_children_map(procs: &[ProcessInfo]) -> HashMap<i32, Vec<i32>> {
315    let mut map: HashMap<i32, Vec<i32>> = HashMap::new();
316
317    for proc in procs {
318        map.entry(proc.ppid).or_default().push(proc.pid);
319    }
320
321    map
322}
323
324/// Collect all descendant PIDs from a children map (built from full scan).
325///
326/// This is the fast path: O(n) where n is number of descendants.
327pub fn collect_descendants_from_map(
328    root_pid: i32,
329    children_map: &HashMap<i32, Vec<i32>>,
330) -> Vec<i32> {
331    let mut result = vec![root_pid];
332    let mut queue = vec![root_pid];
333
334    while let Some(pid) = queue.pop() {
335        if let Some(children) = children_map.get(&pid) {
336            for &child in children {
337                result.push(child);
338                queue.push(child);
339            }
340        }
341    }
342
343    result
344}
345
346/// Get stats for a process tree using the full scan approach.
347///
348/// This scans all processes once, builds a tree, and filters to descendants.
349/// Faster than targeted approach when you have many root PIDs to check.
350pub fn snapshot_tree_from_scan(root_pid: i32, procs: &[ProcessInfo]) -> Option<TreeStats> {
351    // Build children map
352    let children_map = build_children_map(procs);
353
354    // Get all descendant PIDs
355    let pids = collect_descendants_from_map(root_pid, &children_map);
356
357    // Build a pid -> ProcessInfo map for quick lookup
358    let proc_map: HashMap<i32, &ProcessInfo> = procs.iter().map(|p| (p.pid, p)).collect();
359
360    // Check if root exists
361    if !proc_map.contains_key(&root_pid) {
362        return None;
363    }
364
365    let mut total_cpu = CpuTime::default();
366    let mut total_memory = 0u64;
367    let mut valid_pids = Vec::new();
368
369    for pid in pids {
370        if let Some(proc) = proc_map.get(&pid) {
371            total_cpu = total_cpu + proc.cpu_time;
372            total_memory += proc.memory_bytes;
373            valid_pids.push(pid);
374        }
375    }
376
377    Some(TreeStats {
378        cpu_time: total_cpu,
379        memory_bytes: total_memory,
380        process_count: valid_pids.len() as u32,
381        pids: valid_pids,
382    })
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    #[test]
390    fn test_snapshot_self() {
391        let pid = std::process::id() as i32;
392        let stats = snapshot_process(pid);
393        assert!(stats.is_some());
394        let stats = stats.unwrap();
395        assert!(stats.memory_bytes > 0);
396    }
397
398    #[test]
399    fn test_delta_tracker() {
400        let pid = std::process::id() as i32;
401        let mut tracker = DeltaTracker::new();
402
403        // First call returns None (establishing baseline)
404        let first = tracker.get_cpu_percent(pid);
405        assert!(first.is_none());
406
407        // Do some work
408        let mut sum = 0u64;
409        for i in 0..1_000_000 {
410            sum = sum.wrapping_add(i);
411        }
412        std::hint::black_box(sum);
413
414        // Second call returns Some
415        let second = tracker.get_cpu_percent(pid);
416        assert!(second.is_some());
417    }
418
419    #[test]
420    fn test_cumulative_tracker() {
421        let pid = std::process::id() as i32;
422        let mut tracker = CumulativeTracker::new();
423
424        // First call returns 0 (baseline just set)
425        let first = tracker.get_cpu_percent(pid);
426        assert_eq!(first, Some(0.0));
427
428        // Do some work
429        let mut sum = 0u64;
430        for i in 0..1_000_000 {
431            sum = sum.wrapping_add(i);
432        }
433        std::hint::black_box(sum);
434
435        // Second call returns cumulative from baseline
436        let second = tracker.get_cpu_percent(pid);
437        assert!(second.is_some());
438    }
439
440    #[test]
441    fn test_collect_tree_pids() {
442        let pid = std::process::id() as i32;
443        let pids = collect_tree_pids(pid);
444        assert!(pids.contains(&pid));
445    }
446
447    #[test]
448    fn test_is_descendant_of_self() {
449        let map = build_parent_map();
450        let pid = std::process::id() as i32;
451        assert!(is_descendant_of(pid, pid, &map));
452    }
453
454    #[test]
455    fn test_is_descendant_of_parent() {
456        let map = build_parent_map();
457        let pid = std::process::id() as i32;
458        if let Some(&ppid) = map.get(&pid) {
459            assert!(is_descendant_of(pid, ppid, &map));
460        }
461    }
462
463    #[test]
464    fn test_is_descendant_of_unrelated() {
465        let map = build_parent_map();
466        let our_pid = std::process::id() as i32;
467        // PID 1 should NOT be a descendant of our process
468        assert!(!is_descendant_of(1, our_pid, &map));
469    }
470
471    #[test]
472    fn test_is_descendant_of_empty_map() {
473        let empty_map: HashMap<i32, i32> = HashMap::new();
474        assert!(is_descendant_of(100, 100, &empty_map));
475        assert!(!is_descendant_of(100, 200, &empty_map));
476    }
477}