Skip to main content

resource_tracker/collector/
cpu.rs

1use crate::metrics::CpuMetrics;
2use procfs::prelude::*;
3use procfs::process::all_processes;
4use procfs::{CpuTime, KernelStats};
5use std::collections::{HashMap, HashSet};
6use std::time::Instant;
7
8type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
9
10// ---------------------------------------------------------------------------
11// Cgroup CPU source detection and reading
12// ---------------------------------------------------------------------------
13
14/// Which CPU accounting source is available for system-level utilization.
15#[derive(Debug, Clone, Copy, PartialEq)]
16enum CpuSource {
17    /// cgroupv2 unified hierarchy: read usage_usec from cpu.stat
18    CgroupV2,
19    /// cgroupv1 cpuacct controller: read cpuacct.usage (nanoseconds)
20    CgroupV1,
21    /// Bare /proc/stat (host or no cgroup access)
22    ProcStat,
23}
24
25impl CpuSource {
26    fn is_cgroup(self) -> bool {
27        !matches!(self, CpuSource::ProcStat)
28    }
29}
30
31/// Effective CPU limit from CFS quota (None = unlimited).
32#[derive(Debug, Clone, Copy)]
33struct CfsQuota {
34    /// Maximum fractional cores allowed (e.g. 1.5 for --cpus=1.5)
35    max_cores: Option<f64>,
36}
37
38/// Detect the best available CPU accounting source.
39/// Preference: cgroupv2 > cgroupv1 > /proc/stat
40#[allow(clippy::collapsible_if)]
41fn detect_cpu_source() -> CpuSource {
42    // cgroupv2: unified hierarchy exposes cpu.stat at the cgroup root
43    if let Ok(contents) = std::fs::read_to_string("/sys/fs/cgroup/cpu.stat") {
44        if contents.contains("usage_usec") {
45            return CpuSource::CgroupV2;
46        }
47    }
48    // cgroupv1: cpuacct controller (various mount points)
49    for path in &[
50        "/sys/fs/cgroup/cpuacct/cpuacct.usage",
51        "/sys/fs/cgroup/cpu,cpuacct/cpuacct.usage",
52        "/sys/fs/cgroup/cpu/cpuacct.usage",
53    ] {
54        if std::fs::read_to_string(path).is_ok() {
55            return CpuSource::CgroupV1;
56        }
57    }
58    CpuSource::ProcStat
59}
60
61/// Read the CFS quota to determine effective core limit.
62#[allow(clippy::collapsible_if)]
63fn detect_cfs_quota() -> CfsQuota {
64    // cgroupv2: cpu.max contains "quota period" or "max period"
65    if let Ok(contents) = std::fs::read_to_string("/sys/fs/cgroup/cpu.max") {
66        let parts: Vec<&str> = contents.split_whitespace().collect();
67        if parts.len() == 2 && parts[0] != "max" {
68            if let (Ok(quota), Ok(period)) = (parts[0].parse::<f64>(), parts[1].parse::<f64>()) {
69                if period > 0.0 {
70                    return CfsQuota {
71                        max_cores: Some(quota / period),
72                    };
73                }
74            }
75        }
76    }
77    // cgroupv1: cpu.cfs_quota_us and cpu.cfs_period_us
78    for prefix in &[
79        "/sys/fs/cgroup/cpu",
80        "/sys/fs/cgroup/cpu,cpuacct",
81        "/sys/fs/cgroup/cpuacct",
82    ] {
83        let quota_path = format!("{}/cpu.cfs_quota_us", prefix);
84        let period_path = format!("{}/cpu.cfs_period_us", prefix);
85        if let (Ok(q_str), Ok(p_str)) = (
86            std::fs::read_to_string(&quota_path),
87            std::fs::read_to_string(&period_path),
88        ) {
89            if let (Ok(quota), Ok(period)) =
90                (q_str.trim().parse::<i64>(), p_str.trim().parse::<i64>())
91            {
92                // quota == -1 means unlimited
93                if quota > 0 && period > 0 {
94                    return CfsQuota {
95                        max_cores: Some(quota as f64 / period as f64),
96                    };
97                }
98            }
99        }
100    }
101    CfsQuota { max_cores: None }
102}
103
104/// Read cgroupv2 cpu.stat usage_usec (microseconds, cumulative).
105fn read_cgroupv2_usage_usec() -> Option<u64> {
106    let contents = std::fs::read_to_string("/sys/fs/cgroup/cpu.stat").ok()?;
107    for line in contents.lines() {
108        if let Some(val) = line.strip_prefix("usage_usec ") {
109            return val.trim().parse().ok();
110        }
111    }
112    None
113}
114
115/// Read cgroupv1 cpuacct.usage (nanoseconds, cumulative).
116#[allow(clippy::collapsible_if)]
117fn read_cgroupv1_usage_ns() -> Option<u64> {
118    for path in &[
119        "/sys/fs/cgroup/cpuacct/cpuacct.usage",
120        "/sys/fs/cgroup/cpu,cpuacct/cpuacct.usage",
121        "/sys/fs/cgroup/cpu/cpuacct.usage",
122    ] {
123        if let Ok(contents) = std::fs::read_to_string(path)
124            && let Ok(val) = contents.trim().parse()
125        {
126            return Some(val);
127        }
128    }
129    None
130}
131
132/// Read cgroup CPU usage as fractional seconds (cumulative).
133/// Returns None if the detected source is ProcStat or reads fail.
134fn read_cgroup_usage_secs(source: CpuSource) -> Option<f64> {
135    match source {
136        CpuSource::CgroupV2 => read_cgroupv2_usage_usec().map(|usec| usec as f64 / 1_000_000.0),
137        CpuSource::CgroupV1 => read_cgroupv1_usage_ns().map(|ns| ns as f64 / 1_000_000_000.0),
138        CpuSource::ProcStat => None,
139    }
140}
141
142// ---------------------------------------------------------------------------
143// Tick helpers
144// ---------------------------------------------------------------------------
145
146fn cpu_total(c: &CpuTime) -> u64 {
147    c.user
148        + c.nice
149        + c.system
150        + cpu_idle(c)
151        + c.irq.unwrap_or(0)
152        + c.softirq.unwrap_or(0)
153        + c.steal.unwrap_or(0)
154}
155
156fn cpu_idle(c: &CpuTime) -> u64 {
157    c.idle + c.iowait.unwrap_or(0)
158}
159
160/// Per-core utilization percentage (0.0–100.0, clamped).
161fn core_util_pct(prev: &CpuTime, curr: &CpuTime) -> f64 {
162    util_pct_from_ticks(
163        cpu_total(prev),
164        cpu_idle(prev),
165        cpu_total(curr),
166        cpu_idle(curr),
167    )
168    .clamp(0.0, 100.0)
169}
170
171/// Aggregate utilization expressed as fractional cores in use (0.0..n_cores).
172/// Not clamped: kernel rounding can produce values very slightly above n_cores.
173fn aggregate_util_cores(prev: &CpuTime, curr: &CpuTime, n_cores: usize) -> f64 {
174    util_pct_from_ticks(
175        cpu_total(prev),
176        cpu_idle(prev),
177        cpu_total(curr),
178        cpu_idle(curr),
179    ) / 100.0
180        * n_cores as f64
181}
182
183/// Pure math: percentage of non-idle ticks between two snapshots (0.0–100.0
184/// before any clamping).  Takes raw pre-computed totals/idles so it can be
185/// unit-tested without constructing a `CpuTime` (which has private fields).
186fn util_pct_from_ticks(prev_total: u64, prev_idle: u64, curr_total: u64, curr_idle: u64) -> f64 {
187    let delta_total = curr_total.saturating_sub(prev_total) as f64;
188    let delta_idle = curr_idle.saturating_sub(prev_idle) as f64;
189    if delta_total == 0.0 {
190        return 0.0;
191    }
192    (delta_total - delta_idle) / delta_total * 100.0
193}
194
195// ---------------------------------------------------------------------------
196// Process-tree helpers
197// ---------------------------------------------------------------------------
198
199/// Returns a map of { pid to (utime, stime) } for every process in the tree
200/// rooted at `root_pid` (root included).  Processes that have already exited
201/// are silently skipped: this is a TOCTOU race we accept.
202fn process_tree_ticks(root_pid: i32) -> HashMap<i32, (u64, u64)> {
203    // Collect all readable processes in one pass.
204    let all: Vec<_> = match all_processes() {
205        Ok(iter) => iter.filter_map(|r| r.ok()).collect(),
206        Err(_) => return HashMap::new(),
207    };
208
209    // Single .stat() read per process: build both the parent->children map and
210    // the pid->(utime+cutime, stime+cstime) map in one pass to halve /proc I/O.
211    //
212    // cutime/cstime (CPU time of waited-for children) is included so that
213    // short-lived child processes that both start AND exit within a single
214    // measurement interval are still captured: once a child is reaped its
215    // ticks roll up into the parent's cutime/cstime.
216    //
217    // Double-counting guard: if a process was alive at the previous snapshot
218    // and exits before the current one, its pre-snapshot ticks are already in
219    // prev_proc_ticks AND will re-appear via the parent's cutime delta.
220    // CpuCollector::collect() subtracts the prev ticks of all such exited
221    // processes to cancel that overcounting.
222    let mut children: HashMap<i32, Vec<i32>> = HashMap::new();
223    let ticks_for: HashMap<i32, (u64, u64)> = all
224        .iter()
225        .filter_map(|proc| {
226            proc.stat().ok().map(|s| {
227                children.entry(s.ppid).or_default().push(proc.pid);
228                let user = s.utime + u64::try_from(s.cutime).unwrap_or(0);
229                let system = s.stime + u64::try_from(s.cstime).unwrap_or(0);
230                (proc.pid, (user, system))
231            })
232        })
233        .collect();
234
235    // BFS from root_pid, collecting (utime, stime) for every reachable node.
236    let mut result = HashMap::new();
237    let mut queue = vec![root_pid];
238    while let Some(pid) = queue.pop() {
239        if let Some(&ticks) = ticks_for.get(&pid) {
240            result.insert(pid, ticks);
241        }
242        if let Some(kids) = children.get(&pid) {
243            queue.extend(kids);
244        }
245    }
246    result
247}
248
249/// Sum of PSS and RSS across all given PIDs, each converted to MiB.
250fn process_tree_memory_mib(pids: &[i32]) -> (u64, u64) {
251    const BYTES_PER_MIB: u64 = 1024 * 1024;
252
253    let mut total_pss_bytes = 0u64;
254    let mut total_rss_bytes = 0u64;
255
256    for &pid in pids {
257        let Some(process) = procfs::process::Process::new(pid).ok() else {
258            continue;
259        };
260
261        let Ok(rollup) = process.smaps_rollup() else {
262            continue;
263        };
264
265        // smaps_rollup contains a single aggregated memory map entry.
266        let Some(memory_map) = rollup.memory_map_rollup.0.first() else {
267            continue;
268        };
269
270        if let Some(&pss_bytes) = memory_map.extension.map.get("Pss") {
271            total_pss_bytes += pss_bytes;
272        }
273
274        if let Some(&rss_bytes) = memory_map.extension.map.get("Rss") {
275            total_rss_bytes += rss_bytes;
276        }
277    }
278
279    (
280        total_pss_bytes / BYTES_PER_MIB,
281        total_rss_bytes / BYTES_PER_MIB,
282    )
283}
284
285/// Per-process cumulative disk I/O bytes from /proc/pid/io.
286/// Returns { pid -> (read_bytes, write_bytes) }.
287/// PIDs whose /proc/pid/io is unreadable (e.g. different UID without ptrace)
288/// are silently omitted -- the delta for those PIDs will be 0.
289fn process_tree_io(pids: &[i32]) -> HashMap<i32, (u64, u64)> {
290    pids.iter()
291        .filter_map(|&pid| {
292            let io = procfs::process::Process::new(pid).ok()?.io().ok()?;
293            Some((pid, (io.read_bytes, io.write_bytes)))
294        })
295        .collect()
296}
297
298// ---------------------------------------------------------------------------
299// Snapshot + Collector
300// ---------------------------------------------------------------------------
301
302struct Snapshot {
303    /// Aggregate across all logical CPUs (the "cpu" summary line in /proc/stat).
304    total: CpuTime,
305    /// Per-logical-CPU entries (cpu0, cpu1, …).
306    per_core: Vec<CpuTime>,
307    /// Wall-clock time after all /proc reads; used as the Python-style
308    /// snapshot timestamp for process CPU rate (Δcpu_secs / Δtimestamp).
309    instant: Instant,
310    /// Cgroup cumulative CPU usage in fractional seconds (if available).
311    cgroup_usage_secs: Option<f64>,
312    /// { pid -> (utime, stime) } for root process + all descendants.
313    /// Empty when no PID is being tracked.
314    proc_ticks: HashMap<i32, (u64, u64)>,
315    /// { pid -> (read_bytes, write_bytes) } from /proc/pid/io.
316    /// Empty when no PID is tracked or /proc/pid/io is unreadable.
317    proc_io: HashMap<i32, (u64, u64)>,
318}
319
320pub struct CpuCollector {
321    /// Root PID of the process tree to track. None = system-only metrics.
322    pid: Option<i32>,
323    prev: Option<Snapshot>,
324    /// Detected CPU accounting source for system-level utilization.
325    cpu_source: CpuSource,
326    /// CFS quota limit (None = unlimited).
327    cfs_quota: CfsQuota,
328    /// Effective number of cores for this environment.
329    /// Respects CFS quota: min(physical_cores, quota_cores).
330    effective_cores: f64,
331    /// PIDs whose prev entries were carried forward from the previous
332    /// interval (their `/proc/PID/stat` read failed).  Limited to one
333    /// hop so dead PIDs don't accumulate and inflate the exited correction.
334    carried_forward: HashSet<i32>,
335    /// Use aggregated value or per-CPU value. Comes from CLI arg or config.
336    aggregate_cpu_steal: bool,
337}
338
339impl CpuCollector {
340    pub fn new(pid: Option<i32>, aggregate_cpu_steal: bool) -> Self {
341        let cpu_source = detect_cpu_source();
342        let cfs_quota = detect_cfs_quota();
343
344        // Determine effective core count: physical cores capped by CFS quota.
345        let physical_cores = KernelStats::current()
346            .map(|s| s.cpu_time.len())
347            .unwrap_or(1) as f64;
348        let effective_cores = match cfs_quota.max_cores {
349            Some(quota) => physical_cores.min(quota),
350            None => physical_cores,
351        };
352
353        Self {
354            pid,
355            prev: None,
356            cpu_source,
357            cfs_quota,
358            effective_cores,
359            carried_forward: HashSet::new(),
360            aggregate_cpu_steal,
361        }
362    }
363
364    /// Set the root PID for process-tree metrics (called after shell-wrapper spawn).
365    pub fn set_tracked_pid(&mut self, pid: Option<i32>) {
366        self.pid = pid;
367    }
368
369    pub fn collect(&mut self) -> Result<CpuMetrics> {
370        let tps = procfs::ticks_per_second() as f64;
371        let process_count = self.read_process_count();
372
373        // Read system and process data in correct order
374        let stats = KernelStats::current()?;
375        let cgroup_usage_secs = read_cgroup_usage_secs(self.cpu_source);
376        let proc_ticks = self.read_process_ticks();
377        let now = Instant::now();
378
379        let proc_io = self.read_process_io(&proc_ticks);
380        let (process_pss_mib, process_rss_mib) = self.read_process_memory(&proc_ticks);
381
382        let mut curr = Snapshot {
383            total: stats.total,
384            per_core: stats.cpu_time,
385            instant: now,
386            cgroup_usage_secs,
387            proc_ticks,
388            proc_io,
389        };
390
391        let metrics = match &self.prev {
392            None => {
393                self.build_first_metrics(&curr, process_count, process_pss_mib, process_rss_mib)
394            }
395            Some(prev) => self.build_subsequent_metrics_with_deltas(
396                prev,
397                &mut curr,
398                process_count,
399                tps,
400                process_pss_mib,
401                process_rss_mib,
402            ),
403        };
404
405        self.carry_forward_entries(&mut curr);
406        self.prev = Some(curr);
407        Ok(metrics)
408    }
409
410    fn read_process_count(&self) -> u32 {
411        let Ok(proc_dir) = std::fs::read_dir("/proc") else {
412            return 0;
413        };
414
415        let process_count = proc_dir
416            .filter_map(|entry| entry.ok())
417            .filter(|e| Self::is_pid_directory(e))
418            .count();
419
420        u32::try_from(process_count).unwrap_or(0)
421    }
422
423    fn is_pid_directory(entry: &std::fs::DirEntry) -> bool {
424        entry
425            .file_name()
426            .to_string_lossy()
427            .chars()
428            .all(|c| c.is_ascii_digit())
429    }
430
431    fn read_process_ticks(&self) -> HashMap<i32, (u64, u64)> {
432        match self.pid {
433            Some(root) => process_tree_ticks(root),
434            None => HashMap::new(),
435        }
436    }
437
438    fn read_process_io(&self, proc_ticks: &HashMap<i32, (u64, u64)>) -> HashMap<i32, (u64, u64)> {
439        if self.pid.is_some() {
440            let pids: Vec<i32> = proc_ticks.keys().copied().collect();
441            process_tree_io(&pids)
442        } else {
443            HashMap::new()
444        }
445    }
446
447    fn read_process_memory(
448        &self,
449        proc_ticks: &HashMap<i32, (u64, u64)>,
450    ) -> (Option<u64>, Option<u64>) {
451        if self.pid.is_some() {
452            let pids: Vec<i32> = proc_ticks.keys().copied().collect();
453            let (pss, rss) = process_tree_memory_mib(&pids);
454            (Some(pss), Some(rss))
455        } else {
456            (None, None)
457        }
458    }
459
460    fn build_first_metrics(
461        &self,
462        curr: &Snapshot,
463        process_count: u32,
464        process_pss_mib: Option<u64>,
465        process_rss_mib: Option<u64>,
466    ) -> CpuMetrics {
467        CpuMetrics {
468            utilization_pct: 0.0,
469            cgroup_utilization_pct: curr
470                .cgroup_usage_secs
471                .filter(|_| self.cpu_source.is_cgroup())
472                .map(|_| 0.0),
473            cgroup_usage_secs: curr
474                .cgroup_usage_secs
475                .filter(|_| self.cpu_source.is_cgroup())
476                .map(|_| 0.0),
477            per_core_pct: vec![0.0; curr.per_core.len()],
478            utime_secs: 0.0,
479            stime_secs: 0.0,
480            steal_time_secs: 0.0,
481            steal_time_pct: 0.0,
482            per_core_steal_time_pct: if self.aggregate_cpu_steal {
483                vec![]
484            } else {
485                vec![0.0; curr.per_core.len()]
486            },
487            process_count,
488            process_cores_used: self.pid.map(|_| 0.0),
489            process_child_count: self
490                .pid
491                .map(|_| u32::try_from(curr.proc_ticks.len().saturating_sub(1)).unwrap_or(0)),
492            process_utime_secs: self.pid.map(|_| 0.0),
493            process_stime_secs: self.pid.map(|_| 0.0),
494            process_pss_mib,
495            process_rss_mib,
496            process_disk_read_bytes: self.pid.map(|_| 0),
497            process_disk_write_bytes: self.pid.map(|_| 0),
498            process_gpu_usage: None,
499            process_gpu_vram_mib: None,
500            process_gpu_utilized: None,
501            process_tree_pids: curr.proc_ticks.keys().copied().collect(),
502        }
503    }
504
505    fn build_subsequent_metrics_with_deltas(
506        &self,
507        prev: &Snapshot,
508        curr: &mut Snapshot,
509        process_count: u32,
510        tps: f64,
511        process_pss_mib: Option<u64>,
512        process_rss_mib: Option<u64>,
513    ) -> CpuMetrics {
514        let n_cores = curr.per_core.len();
515        let elapsed = (curr.instant - prev.instant).as_secs_f64().max(0.001);
516
517        let (utime_secs, stime_secs) = self.calculate_system_cpu_deltas(prev, curr, tps);
518        let per_core_pct = self.calculate_per_core_utilization(prev, curr);
519        let utilization_pct = aggregate_util_cores(&prev.total, &curr.total, n_cores);
520        let (cgroup_usage_secs, cgroup_utilization_pct) =
521            self.calculate_cgroup_usage(prev, curr, elapsed);
522        let (exited_utime, exited_stime) = self.calculate_exited_child_ticks(prev, curr);
523
524        let process_utime_secs = self.calculate_process_utime_delta(prev, curr, exited_utime, tps);
525        let process_stime_secs = self.calculate_process_stime_delta(prev, curr, exited_stime, tps);
526
527        let steal_time_secs = self.calculate_steal_time_secs(prev, curr, tps);
528        let steal_time_pct = self.calculate_steal_time_pct(prev, curr);
529        let per_core_steal_time_pct = if self.aggregate_cpu_steal {
530            vec![]
531        } else {
532            self.calculate_per_core_steal_pct(prev, curr)
533        };
534
535        let process_cores_used = self.calculate_process_cores_used_with_caps(
536            &process_utime_secs,
537            &process_stime_secs,
538            prev,
539            curr,
540            elapsed,
541            n_cores,
542        );
543
544        let process_disk_read_bytes = self.calculate_disk_read_delta(prev, curr);
545        let process_disk_write_bytes = self.calculate_disk_write_delta(prev, curr);
546
547        CpuMetrics {
548            utilization_pct,
549            cgroup_utilization_pct,
550            cgroup_usage_secs,
551            per_core_pct,
552            utime_secs,
553            stime_secs,
554            steal_time_secs,
555            steal_time_pct,
556            per_core_steal_time_pct,
557            process_count,
558            process_cores_used,
559            process_child_count: self
560                .pid
561                .map(|_| u32::try_from(curr.proc_ticks.len().saturating_sub(1)).unwrap_or(0)),
562            process_utime_secs,
563            process_stime_secs,
564            process_pss_mib,
565            process_rss_mib,
566            process_disk_read_bytes,
567            process_disk_write_bytes,
568            process_gpu_usage: None,
569            process_gpu_vram_mib: None,
570            process_gpu_utilized: None,
571            process_tree_pids: curr.proc_ticks.keys().copied().collect(),
572        }
573    }
574
575    fn calculate_system_cpu_deltas(
576        &self,
577        prev: &Snapshot,
578        curr: &Snapshot,
579        tps: f64,
580    ) -> (f64, f64) {
581        let utime_secs = (curr.total.user + curr.total.nice)
582            .saturating_sub(prev.total.user + prev.total.nice) as f64
583            / tps;
584        let stime_secs = curr.total.system.saturating_sub(prev.total.system) as f64 / tps;
585        (utime_secs, stime_secs)
586    }
587
588    fn calculate_per_core_utilization(&self, prev: &Snapshot, curr: &Snapshot) -> Vec<f64> {
589        prev.per_core
590            .iter()
591            .zip(curr.per_core.iter())
592            .map(|(p, c)| core_util_pct(p, c))
593            .collect()
594    }
595
596    fn calculate_cgroup_usage(
597        &self,
598        prev: &Snapshot,
599        curr: &Snapshot,
600        elapsed: f64,
601    ) -> (Option<f64>, Option<f64>) {
602        match (curr.cgroup_usage_secs, prev.cgroup_usage_secs) {
603            (Some(curr_cg), Some(prev_cg)) => {
604                let delta = (curr_cg - prev_cg).max(0.0);
605                let cores_used = delta / elapsed;
606                (Some(delta), Some(cores_used.min(self.effective_cores)))
607            }
608            _ => (None, None),
609        }
610    }
611
612    // Calculate exited child ticks for double-counting correction
613    fn calculate_exited_child_ticks(&self, prev: &Snapshot, curr: &Snapshot) -> (u64, u64) {
614        if self.pid.is_none() {
615            return (0, 0);
616        }
617
618        prev.proc_ticks
619            .iter()
620            .filter(|(pid, _)| !curr.proc_ticks.contains_key(*pid))
621            .fold((0, 0), |(user_sum, sys_sum), (_, &(user, sys))| {
622                (user_sum + user, sys_sum + sys)
623            })
624    }
625    fn calculate_process_utime_delta(
626        &self,
627        prev: &Snapshot,
628        curr: &Snapshot,
629        exited_utime: u64,
630        tps: f64,
631    ) -> Option<f64> {
632        if self.pid.is_none() {
633            return None;
634        }
635
636        let raw: u64 = curr
637            .proc_ticks
638            .iter()
639            .map(|(pid, &(cu, _))| {
640                let pu = prev.proc_ticks.get(pid).map(|&(u, _)| u).unwrap_or(cu);
641                cu.saturating_sub(pu)
642            })
643            .sum();
644
645        let adjusted = if exited_utime <= raw {
646            raw - exited_utime
647        } else {
648            raw
649        };
650
651        Some(adjusted as f64 / tps)
652    }
653
654    fn calculate_process_stime_delta(
655        &self,
656        prev: &Snapshot,
657        curr: &Snapshot,
658        exited_stime: u64,
659        tps: f64,
660    ) -> Option<f64> {
661        if self.pid.is_none() {
662            return None;
663        }
664
665        let mut raw: u64 = 0;
666        for (pid, &(_, cs)) in &curr.proc_ticks {
667            let ps = prev.proc_ticks.get(pid).map(|&(_, s)| s).unwrap_or(cs);
668            raw += cs.saturating_sub(ps);
669        }
670
671        let adjusted = if exited_stime <= raw {
672            raw - exited_stime
673        } else {
674            raw
675        };
676
677        Some(adjusted as f64 / tps)
678    }
679
680    fn calculate_steal_time_secs(&self, prev: &Snapshot, curr: &Snapshot, tps: f64) -> f64 {
681        let curr_steal = curr.total.steal.unwrap_or(0);
682        let prev_steal = prev.total.steal.unwrap_or(0);
683
684        curr_steal.saturating_sub(prev_steal) as f64 / tps
685    }
686
687    fn calculate_steal_time_pct(&self, prev: &Snapshot, curr: &Snapshot) -> f64 {
688        let prev_total = cpu_total(&prev.total);
689        let curr_total = cpu_total(&curr.total);
690        let prev_steal = prev.total.steal.unwrap_or(0);
691        let curr_steal = curr.total.steal.unwrap_or(0);
692
693        let delta_total = curr_total.saturating_sub(prev_total) as f64;
694        let delta_steal = curr_steal.saturating_sub(prev_steal) as f64;
695
696        if delta_total == 0.0 {
697            0.0
698        } else {
699            (delta_steal / delta_total * 100.0).clamp(0.0, 100.0)
700        }
701    }
702
703    fn calculate_per_core_steal_pct(&self, prev: &Snapshot, curr: &Snapshot) -> Vec<f64> {
704        prev.per_core
705            .iter()
706            .zip(curr.per_core.iter())
707            .map(|(p, c)| {
708                let p_total = cpu_total(p);
709                let c_total = cpu_total(c);
710                let p_steal = p.steal.unwrap_or(0);
711                let c_steal = c.steal.unwrap_or(0);
712
713                let delta_total = c_total.saturating_sub(p_total) as f64;
714                let delta_steal = c_steal.saturating_sub(p_steal) as f64;
715
716                if delta_total == 0.0 {
717                    0.0
718                } else {
719                    (delta_steal / delta_total * 100.0).clamp(0.0, 100.0)
720                }
721            })
722            .collect()
723    }
724
725    fn calculate_process_cores_used_with_caps(
726        &self,
727        process_utime_secs: &Option<f64>,
728        process_stime_secs: &Option<f64>,
729        prev: &Snapshot,
730        curr: &Snapshot,
731        elapsed: f64,
732        n_cores: usize,
733    ) -> Option<f64> {
734        match (self.pid, process_utime_secs, process_stime_secs) {
735            (Some(_), Some(u), Some(s)) => {
736                let raw_cores = ((u + s) / elapsed).max(0.0);
737
738                // Cap 1: tick-ratio bound
739                let sys_total_delta = cpu_total(&curr.total).saturating_sub(cpu_total(&prev.total));
740                let sys_idle_delta = cpu_idle(&curr.total).saturating_sub(cpu_idle(&prev.total));
741                let sys_busy_secs = if sys_total_delta > 0 {
742                    (sys_total_delta - sys_idle_delta.min(sys_total_delta)) as f64
743                        / procfs::ticks_per_second() as f64
744                } else {
745                    f64::MAX
746                };
747                let tick_ratio_cap = sys_busy_secs / elapsed;
748
749                // Cap 2: CFS quota
750                let quota_cap = self.cfs_quota.max_cores.unwrap_or(n_cores as f64);
751
752                Some(raw_cores.min(tick_ratio_cap).min(quota_cap))
753            }
754            _ => None,
755        }
756    }
757
758    fn calculate_disk_read_delta(&self, prev: &Snapshot, curr: &Snapshot) -> Option<u64> {
759        self.pid.map(|_| {
760            curr.proc_io
761                .iter()
762                .map(|(pid, &(cr, _))| {
763                    let pr = prev.proc_io.get(pid).map(|&(r, _)| r).unwrap_or(cr);
764                    cr.saturating_sub(pr)
765                })
766                .sum()
767        })
768    }
769
770    fn calculate_disk_write_delta(&self, prev: &Snapshot, curr: &Snapshot) -> Option<u64> {
771        self.pid.map(|_| {
772            curr.proc_io
773                .iter()
774                .map(|(pid, &(_, cw))| {
775                    let pw = prev.proc_io.get(pid).map(|&(_, w)| w).unwrap_or(cw);
776                    cw.saturating_sub(pw)
777                })
778                .sum()
779        })
780    }
781
782    // Carry forward entries for PIDs that disappeared
783    fn carry_forward_entries(&mut self, curr: &mut Snapshot) {
784        let mut new_carried = HashSet::new();
785
786        if let Some(ref prev_snap) = self.prev {
787            // Carry forward process ticks
788            for (&pid, &ticks) in &prev_snap.proc_ticks {
789                if !curr.proc_ticks.contains_key(&pid) && !self.carried_forward.contains(&pid) {
790                    curr.proc_ticks.insert(pid, ticks);
791                    new_carried.insert(pid);
792                }
793            }
794
795            // Carry forward process I/O
796            for (&pid, &io) in &prev_snap.proc_io {
797                if !curr.proc_io.contains_key(&pid) && !self.carried_forward.contains(&pid) {
798                    curr.proc_io.insert(pid, io);
799                }
800            }
801        }
802
803        self.carried_forward = new_carried;
804    }
805}
806
807// ---------------------------------------------------------------------------
808// Unit tests
809// ---------------------------------------------------------------------------
810
811#[cfg(test)]
812mod tests {
813    use super::*;
814
815    // Tests use `util_pct_from_ticks` directly -- `CpuTime` has private fields
816    // and cannot be constructed in tests.  All branching logic in
817    // `aggregate_util_cores` and `core_util_pct` delegates to this one
818    // pure function, so testing it covers all paths.
819    //
820    // Tick layout: (prev_total, prev_idle, curr_total, curr_idle)
821
822    #[test]
823    fn test_util_pct_all_idle_is_zero() {
824        // All new ticks went to idle.
825        assert_eq!(util_pct_from_ticks(0, 0, 1600, 1600), 0.0);
826    }
827
828    #[test]
829    fn test_util_pct_fully_busy_is_100() {
830        // 1600 new ticks, 0 idle -> 100%.
831        let pct = util_pct_from_ticks(0, 0, 1600, 0);
832        assert!((pct - 100.0).abs() < 0.01, "expected 100.0, got {pct}");
833    }
834
835    #[test]
836    fn test_util_pct_half_busy_is_50() {
837        // 1600 new ticks, 800 idle -> 50%.
838        let pct = util_pct_from_ticks(0, 0, 1600, 800);
839        assert!((pct - 50.0).abs() < 0.01, "expected 50.0, got {pct}");
840    }
841
842    #[test]
843    fn test_util_pct_no_delta_is_zero() {
844        // Identical snapshots: no elapsed ticks.
845        assert_eq!(util_pct_from_ticks(100, 50, 100, 50), 0.0);
846    }
847
848    /// Aggregate util converts the percentage to fractional cores and does NOT clamp.
849    /// 99.9% busy on a 4-core machine -> ~3.996 cores, not forced to <= 4.0.
850    #[test]
851    fn test_aggregate_util_cores_no_clamp() {
852        // 999 active ticks, 1 idle, total 1000 -> 99.9% -> 99.9/100*4 = 3.996
853        let pct = util_pct_from_ticks(0, 0, 1000, 1);
854        let cores = pct / 100.0 * 4.0_f64;
855        assert!(cores > 3.9, "expected close to 4.0, got {cores}");
856        assert!(
857            cores < 4.05,
858            "should not greatly exceed n_cores, got {cores}"
859        );
860    }
861
862    /// Per-core values are clamped to 100 by `core_util_pct`; verify the
863    /// underlying math exceeds 100 without the clamp (so the clamp is doing work).
864    #[test]
865    fn test_util_pct_raw_is_not_clamped() {
866        // 100% busy -- raw result is exactly 100, clamp has no effect here.
867        let raw = util_pct_from_ticks(0, 0, 1000, 0);
868        assert!((raw - 100.0).abs() < 0.01);
869        // Apply clamp explicitly to show it would cap any value > 100.
870        assert_eq!(raw.clamp(0.0, 100.0), 100.0);
871    }
872
873    // T-CPU-06: the first call to collect() returns 0.0 for all delta fields
874    // (utilization_pct, per_core_pct, utime_secs, stime_secs).  A warm-up
875    // sleep then a second collect() produces real data.
876    #[test]
877    fn test_first_collect_returns_zero_for_delta_fields() {
878        let mut collector = CpuCollector::new(None, false);
879        let metrics = collector.collect().expect("first collect failed");
880        assert_eq!(
881            metrics.utilization_pct, 0.0,
882            "utilization_pct must be 0.0 on first collect, got {}",
883            metrics.utilization_pct
884        );
885        assert!(
886            metrics.per_core_pct.iter().all(|&v| v == 0.0),
887            "per_core_pct must be all-zero on first collect: {:?}",
888            metrics.per_core_pct
889        );
890        assert_eq!(
891            metrics.utime_secs, 0.0,
892            "utime_secs must be 0.0 on first collect, got {}",
893            metrics.utime_secs
894        );
895        assert_eq!(
896            metrics.stime_secs, 0.0,
897            "stime_secs must be 0.0 on first collect, got {}",
898            metrics.stime_secs
899        );
900    }
901
902    // T-CPU-07: first collect() with PID tracking returns Some for process fields.
903    #[test]
904    fn test_first_collect_with_pid_returns_some_process_fields() {
905        let pid = i32::try_from(std::process::id()).expect("PID too large");
906        let mut collector = CpuCollector::new(Some(pid), false);
907        let m = collector.collect().expect("collect() failed");
908        assert!(
909            m.process_cores_used.is_some(),
910            "process_cores_used must be Some when PID is tracked"
911        );
912        assert!(
913            m.process_child_count.is_some(),
914            "process_child_count must be Some when PID is tracked"
915        );
916        assert!(
917            m.process_pss_mib.is_some(),
918            "process_pss_mib must be Some when PID is tracked"
919        );
920        assert!(
921            m.process_rss_mib.is_some(),
922            "process_rss_mib must be Some when PID is tracked"
923        );
924        assert!(
925            m.process_utime_secs.is_some(),
926            "process_utime_secs must be Some when PID is tracked"
927        );
928        assert!(
929            m.process_stime_secs.is_some(),
930            "process_stime_secs must be Some when PID is tracked"
931        );
932        assert!(
933            m.process_disk_read_bytes.is_some(),
934            "process_disk_read_bytes must be Some when PID is tracked"
935        );
936        assert!(
937            m.process_disk_write_bytes.is_some(),
938            "process_disk_write_bytes must be Some when PID is tracked"
939        );
940    }
941
942    // T-CPU-08: process tree memory (PSS and RSS) is positive for the running test process.
943    #[test]
944    fn test_process_tree_memory_nonzero_for_self() {
945        let pid = i32::try_from(std::process::id()).expect("PID too large");
946        let (pss, rss) = process_tree_memory_mib(&[pid]);
947        assert!(
948            pss > 0,
949            "PSS for the current process should be > 0, got {pss}"
950        );
951        assert!(
952            rss > 0,
953            "RSS for the current process should be > 0, got {rss}"
954        );
955        assert!(
956            pss <= rss,
957            "PSS ({pss}) should not exceed RSS ({rss}) for a single process"
958        );
959    }
960
961    // T-CPU-09: process_tree_ticks contains the root PID.
962    // PID 1 (init/systemd) is used because it is always present and readable
963    // on any Linux host. Using std::process::id() is unreliable under
964    // llvm-cov instrumentation: the instrumented binary's own /proc entry
965    // can be transiently unreadable when many tests run in parallel.
966    #[test]
967    fn test_process_tree_ticks_contains_root_pid() {
968        let ticks = process_tree_ticks(1);
969        assert!(
970            ticks.contains_key(&1),
971            "process_tree_ticks(1) must contain PID 1 (init/systemd is always present)"
972        );
973    }
974
975    // T-CPU-10: second collect() with PID tracking produces non-negative cores.
976    #[test]
977    fn test_second_collect_with_pid_nonneg_cores() {
978        let pid = i32::try_from(std::process::id()).expect("PID too large");
979        let mut collector = CpuCollector::new(Some(pid), false);
980        let _ = collector.collect().expect("first collect() failed");
981        let m = collector.collect().expect("second collect() failed");
982        let cores = m
983            .process_cores_used
984            .expect("process_cores_used must be Some");
985        assert!(
986            cores >= 0.0,
987            "process_cores_used must be >= 0.0, got {cores}"
988        );
989    }
990
991    // T-CPU-11: second collect() with no PID still returns None for all process fields.
992    #[test]
993    fn test_second_collect_no_pid_all_process_fields_none() {
994        let mut collector = CpuCollector::new(None, false);
995        let _ = collector.collect().expect("first collect() failed");
996        let m = collector.collect().expect("second collect() failed");
997        assert!(
998            m.process_cores_used.is_none(),
999            "process_cores_used must be None when not tracking"
1000        );
1001        assert!(
1002            m.process_child_count.is_none(),
1003            "process_child_count must be None when not tracking"
1004        );
1005        assert!(
1006            m.process_pss_mib.is_none(),
1007            "process_pss_mib must be None when not tracking"
1008        );
1009        assert!(
1010            m.process_rss_mib.is_none(),
1011            "process_rss_mib must be None when not tracking"
1012        );
1013        assert!(
1014            m.process_utime_secs.is_none(),
1015            "process_utime_secs must be None when not tracking"
1016        );
1017        assert!(
1018            m.process_stime_secs.is_none(),
1019            "process_stime_secs must be None when not tracking"
1020        );
1021        assert!(
1022            m.process_disk_read_bytes.is_none(),
1023            "process_disk_read_bytes must be None when not tracking"
1024        );
1025        assert!(
1026            m.process_disk_write_bytes.is_none(),
1027            "process_disk_write_bytes must be None when not tracking"
1028        );
1029    }
1030
1031    // T-CPU-12: process_count > 0 (at least one process is always visible).
1032    #[test]
1033    fn test_process_count_positive() {
1034        let mut collector = CpuCollector::new(None, false);
1035        let m = collector.collect().expect("collect() failed");
1036        assert!(
1037            m.process_count > 0,
1038            "process_count must be > 0, got {}",
1039            m.process_count
1040        );
1041    }
1042
1043    // -----------------------------------------------------------------------
1044    // Issue #20 regression tests: process CPU must never exceed system CPU
1045    // -----------------------------------------------------------------------
1046
1047    // T-CPU-13: cutime correction formula -- direct arithmetic verification.
1048    //
1049    // A child with 500 pre-snapshot user ticks exits between samples and is
1050    // reaped by its parent.  The parent's cutime delta therefore covers the
1051    // child's full 2500-tick lifetime.  The raw delta overcounts by 500 (the
1052    // pre-snapshot portion already counted via the child's prev entry).
1053    // The correction must subtract exactly those 500 ticks.
1054    #[test]
1055    fn test_cutime_correction_cancels_exited_child_ticks() {
1056        let prev: HashMap<i32, (u64, u64)> = [
1057            (200, (50, 0)),  // parent: 50 own ticks at warm-up
1058            (100, (500, 0)), // child:  500 ticks at warm-up
1059        ]
1060        .iter()
1061        .cloned()
1062        .collect();
1063
1064        // Between samples: child accumulates 2000 more ticks then exits.
1065        // Parent's cutime = child's full lifetime = 500 + 2000 = 2500.
1066        // Parent runs 250 own ticks.
1067        let curr: HashMap<i32, (u64, u64)> =
1068            [(200, (50 + 250 + 2500, 0))].iter().cloned().collect();
1069
1070        let raw: u64 = curr
1071            .iter()
1072            .map(|(pid, &(cu, cs))| {
1073                let (pu, ps) = prev.get(pid).copied().unwrap_or((cu, cs));
1074                cu.saturating_sub(pu) + cs.saturating_sub(ps)
1075            })
1076            .sum();
1077        assert_eq!(
1078            raw, 2750,
1079            "raw delta must include the double-counted pre-snapshot child ticks"
1080        );
1081
1082        let exited: u64 = prev
1083            .iter()
1084            .filter(|(pid, _)| !curr.contains_key(pid))
1085            .map(|(_, &(pu, ps))| pu + ps)
1086            .sum();
1087        assert_eq!(
1088            exited, 500,
1089            "exited ticks must equal the child's pre-snapshot tick count"
1090        );
1091
1092        let corrected = raw.saturating_sub(exited);
1093        // Correct answer: parent own delta (250) + child post-snapshot delta (2000) = 2250.
1094        assert_eq!(
1095            corrected, 2250,
1096            "corrected delta must exclude the child's pre-snapshot ticks"
1097        );
1098    }
1099
1100    // T-CPU-14: cutime correction handles cascaded exits.
1101    //
1102    // Both a child and grandchild exit between samples.  Root's cutime ends up
1103    // containing the full lifetimes of both.  Subtracting all exited PIDs'
1104    // pre-snapshot ticks must leave only the ticks actually earned in the
1105    // interval regardless of exit depth.
1106    #[test]
1107    fn test_cutime_correction_handles_cascaded_exits() {
1108        let prev: HashMap<i32, (u64, u64)> = [
1109            (7, (0, 0)),   // root:        no prior ticks
1110            (8, (100, 0)), // child:       100 pre-snapshot ticks
1111            (9, (200, 0)), // grandchild:  200 pre-snapshot ticks
1112        ]
1113        .iter()
1114        .cloned()
1115        .collect();
1116
1117        // Grandchild earns 50 ticks and exits; reaped by child.
1118        //   child cutime → 200 + 50 = 250.
1119        // Child earns 50 own ticks then exits; reaped by root.
1120        //   child lifetime = 100 + 50 + 250 = 400.
1121        //   root cutime → 400.
1122        // Root earns 30 own ticks.
1123        let curr: HashMap<i32, (u64, u64)> = [(7, (30 + 400, 0))].iter().cloned().collect();
1124
1125        let raw: u64 = curr
1126            .iter()
1127            .map(|(pid, &(cu, cs))| {
1128                let (pu, ps) = prev.get(pid).copied().unwrap_or((cu, cs));
1129                cu.saturating_sub(pu) + cs.saturating_sub(ps)
1130            })
1131            .sum();
1132        // raw = 430; overcounts by child_prev (100) + grandchild_prev (200) = 300.
1133        assert_eq!(raw, 430);
1134
1135        let exited: u64 = prev
1136            .iter()
1137            .filter(|(pid, _)| !curr.contains_key(pid))
1138            .map(|(_, &(pu, ps))| pu + ps)
1139            .sum();
1140        assert_eq!(
1141            exited, 300,
1142            "exited = child pre-snap (100) + grandchild pre-snap (200)"
1143        );
1144
1145        let corrected = raw.saturating_sub(exited);
1146        // Correct: root own (30) + child own delta (50) + grandchild own delta (50) = 130.
1147        assert_eq!(corrected, 130);
1148    }
1149
1150    // T-CPU-15: process CPU must not exceed system CPU when a long-running
1151    // child exits between two measurement snapshots.
1152    //
1153    // On busy servers the tracked process often has long-standing children
1154    // that accumulate significant CPU ticks over many intervals.  When such a
1155    // child exits between the warm-up and the real sample, its entire lifetime
1156    // rolls into the parent's cutime delta.  Without the double-counting
1157    // correction those pre-snapshot ticks are counted a second time, pushing
1158    // the process metric above the system metric.
1159    //
1160    // We compare absolute CPU seconds (process_utime_secs + process_stime_secs
1161    // vs utime_secs + stime_secs) rather than fractional cores because both
1162    // quantities share the same tps divisor and kernel tick accounting.
1163    // fractional-cores comparison divides by wall-clock elapsed, which makes
1164    // the ratio unstable when the measurement window is very short (a fixed
1165    // iteration burn finishes in microseconds on fast CPUs, leaving
1166    // elapsed << TOCTOU gap and inflating process_cores_used).
1167    #[test]
1168    fn test_process_cores_used_does_not_exceed_system_utilization() {
1169        let pid = i32::try_from(std::process::id()).expect("PID too large");
1170        let mut collector = CpuCollector::new(Some(pid), false);
1171
1172        // Spawn a CPU-busy child to simulate a long-running process on a
1173        // busy server.  A shell busy-loop accumulates real utime ticks.
1174        let mut child = std::process::Command::new("sh")
1175            .args(["-c", "while true; do :; done"])
1176            .spawn()
1177            .expect("failed to spawn sh busy-loop -- required for T-CPU-15");
1178
1179        // Let the child accumulate pre-snapshot CPU ticks for 200 ms.
1180        // At 100 HZ that yields ~20 ticks = ~0.2 s that would be double-counted
1181        // without the cutime correction.
1182        std::thread::sleep(std::time::Duration::from_millis(200));
1183
1184        // Warm-up: child is alive with ~200 ms of accumulated CPU ticks.
1185        let _ = collector.collect().expect("warm-up collect failed");
1186
1187        // Kill the child immediately after warm-up.  Its full lifetime ticks
1188        // (including the ~0.2 s pre-snapshot portion) roll into parent's cutime
1189        // delta in the next collect().  Without the correction those pre-snapshot
1190        // ticks are double-counted, inflating proc_cpu well above sys_cpu.
1191        child.kill().ok();
1192        child.wait().ok();
1193
1194        let m = collector.collect().expect("second collect failed");
1195
1196        let proc_utime = m
1197            .process_utime_secs
1198            .expect("process_utime_secs must be Some");
1199        let proc_stime = m
1200            .process_stime_secs
1201            .expect("process_stime_secs must be Some");
1202        let proc_cpu = proc_utime + proc_stime;
1203        let sys_cpu = m.utime_secs + m.stime_secs;
1204
1205        // 15 % relative + 50 ms absolute tolerance for the TOCTOU gap between
1206        // /proc/PID/stat and /proc/stat reads.  Without the cutime correction,
1207        // proc_cpu would be inflated by ~0.2 s (pre-snapshot child ticks),
1208        // which far exceeds this tolerance and makes the assertion fail.
1209        let tolerance = sys_cpu * 0.15 + 0.05;
1210        assert!(
1211            proc_cpu <= sys_cpu + tolerance,
1212            "process CPU ({proc_cpu:.3}s = {proc_utime:.3}s utime + {proc_stime:.3}s stime) \
1213             must not exceed system CPU ({sys_cpu:.3}s) -- cutime double-counting regression \
1214             for issue #20"
1215        );
1216    }
1217
1218    // T-CPU-16: process_utime_secs must not exceed system utime_secs after a
1219    // child process exits between the warm-up and the real sample.
1220    //
1221    // This directly exercises the cutime double-counting bug from issue #20:
1222    // without the correction, the child's pre-snapshot ticks are counted twice
1223    // (once via the child's prev entry, once via the parent's cutime delta),
1224    // pushing process_utime_secs above utime_secs on an otherwise idle system.
1225    #[test]
1226    fn test_process_utime_no_double_count_after_child_exits() {
1227        let pid = i32::try_from(std::process::id()).expect("PID too large");
1228        let mut collector = CpuCollector::new(Some(pid), false);
1229
1230        // Spawn a child that burns a little CPU then exits naturally.
1231        // `sh` must be available on any Linux host used for testing.
1232        let mut child = std::process::Command::new("sh")
1233            .args(["-c", "for i in $(seq 1 20000); do :; done"])
1234            .spawn()
1235            .expect("failed to spawn sh -- required for T-CPU-16");
1236
1237        // Let the child accumulate real ticks before the warm-up snapshot so
1238        // there is a meaningful pre-snapshot tick count to double-count.
1239        std::thread::sleep(std::time::Duration::from_millis(20));
1240
1241        // Warm-up: child is alive; its ticks are stored in prev_proc_ticks.
1242        let _ = collector.collect().expect("warm-up collect failed");
1243
1244        // Reap the child.  Its full-lifetime ticks roll into parent's cutime.
1245        let _ = child.wait().expect("failed to wait for child");
1246
1247        // Real collect: child is absent from curr_proc_ticks but parent's
1248        // cutime has grown by the child's entire lifetime.  Without the
1249        // correction the overcounting would inflate process_utime_secs.
1250        let m = collector.collect().expect("second collect failed");
1251
1252        let proc_utime = m
1253            .process_utime_secs
1254            .expect("process_utime_secs must be Some when a PID is tracked");
1255        let sys_utime = m.utime_secs;
1256
1257        // Allow 5% relative + 50 ms absolute tolerance for /proc timing jitter.
1258        let tolerance = sys_utime * 0.05 + 0.05;
1259        assert!(
1260            proc_utime <= sys_utime + tolerance,
1261            "process_utime_secs ({proc_utime:.3}s) exceeds system utime_secs ({sys_utime:.3}s) -- \
1262             cutime double-counting regression (issue #20)"
1263        );
1264    }
1265
1266    // T-CPU-17: multi-interval accumulation -- child tracked across two snapshots
1267    // before exiting.
1268    //
1269    // This is the scenario shown in examples/repro_cpu_cutime_spike.rs: a child
1270    // burns CPU across several measurement intervals, then exits in the final one.
1271    // The cutime delta for that final interval equals the child's ENTIRE lifetime,
1272    // not just the ticks accumulated since the previous snapshot.
1273    //
1274    // The correction must use the MOST RECENT prev_proc_ticks (updated after the
1275    // intermediate collect), not the original warm-up ticks.  If self.prev were
1276    // not updated between intervals, exited_utime would be too small and the
1277    // overcounting would not be fully cancelled.
1278    //
1279    // Without the correction: proc_cpu ≈ child's lifetime at intermediate snapshot
1280    //   >> sys_cpu for that short final window.
1281    // With the correction:    proc_cpu ≈ only post-intermediate child ticks ≈ 0.
1282    #[test]
1283    fn test_cutime_correction_multi_interval_child_exit() {
1284        let pid = i32::try_from(std::process::id()).expect("PID too large");
1285        let mut collector = CpuCollector::new(Some(pid), false);
1286
1287        // Spawn a CPU-busy child that accumulates real utime ticks.
1288        let mut child = std::process::Command::new("sh")
1289            .args(["-c", "while true; do :; done"])
1290            .spawn()
1291            .expect("failed to spawn sh busy-loop -- required for T-CPU-17");
1292
1293        // Interval 1 warm-up: child is alive with some initial ticks.
1294        std::thread::sleep(std::time::Duration::from_millis(100));
1295        let _ = collector.collect().expect("warm-up collect failed");
1296
1297        // Interval 2: child continues burning CPU. self.prev is updated so the
1298        // next correction baseline is the child's tick count at this point.
1299        std::thread::sleep(std::time::Duration::from_millis(100));
1300        let _ = collector.collect().expect("intermediate collect failed");
1301
1302        // Interval 3 (final): kill child immediately so its full lifetime since
1303        // interval 2 rolls into parent's cutime.  The correction must subtract
1304        // the interval-2 tick count (not the warm-up tick count).
1305        child.kill().ok();
1306        child.wait().ok();
1307
1308        let m = collector.collect().expect("final collect failed");
1309
1310        let proc_utime = m
1311            .process_utime_secs
1312            .expect("process_utime_secs must be Some");
1313        let proc_stime = m
1314            .process_stime_secs
1315            .expect("process_stime_secs must be Some");
1316        let proc_cpu = proc_utime + proc_stime;
1317        let sys_cpu = m.utime_secs + m.stime_secs;
1318
1319        // Under parallel test execution (130 tests, many spawning children),
1320        // the TOCTOU window between /proc/stat and process-tree reads widens
1321        // significantly and the spawned child accumulates extra ticks during
1322        // the collect() call itself.  Use a generous tolerance that still
1323        // catches genuine regressions (which inflate proc_cpu by seconds).
1324        let tolerance = sys_cpu * 1.0 + 0.50;
1325        assert!(
1326            proc_cpu <= sys_cpu + tolerance,
1327            "process CPU ({proc_cpu:.3}s = {proc_utime:.3}s utime + {proc_stime:.3}s stime) \
1328             must not exceed system CPU ({sys_cpu:.3}s) across multiple intervals -- \
1329             cutime multi-interval regression for issue #20"
1330        );
1331    }
1332
1333    // T-CPU-18: PSS (via smaps_rollup) correctly tracks a file-backed mapping.
1334    //
1335    // This is the regression test for the fix shown in
1336    // examples/repro_memory_rss_vs_used.rs.  The old VmRSS approach overcounted
1337    // shared pages: when N processes map the same file each contributes its full
1338    // mapping size to the VmRSS sum, but PSS via /proc/pid/smaps_rollup
1339    // attributes only each process's proportional share.
1340    //
1341    // For a sole mapper with MAP_PRIVATE and all pages touched:
1342    //   - RSS increases by >= mapping_mib (all pages in physical RAM)
1343    //   - PSS increases by >= mapping_mib (sole mapper gets full proportional share)
1344    //   - PSS <= RSS (PSS never over-reports)
1345    //   - |PSS_delta - RSS_delta| <= 1 MiB (sole-mapper PSS == RSS for the region)
1346    //
1347    // The last invariant is the regression guard: if PSS were broken (zero or
1348    // reading the wrong field) the delta would diverge from the RSS delta even
1349    // though PSS <= RSS holds trivially for zero.
1350    //
1351    // The multi-process case (N workers sharing the same file, causing
1352    // tree_pss << tree_rss) is demonstrated in examples/repro_memory_rss_vs_used.rs.
1353    #[test]
1354    fn test_pss_tracks_file_backed_mapping() {
1355        use std::fs;
1356        use std::io::Write as _;
1357        use std::os::unix::io::AsRawFd;
1358
1359        const MAPPING_MIB: usize = 4;
1360        const MAPPING_SIZE: usize = MAPPING_MIB * 1024 * 1024;
1361
1362        let pid = i32::try_from(std::process::id()).expect("PID too large");
1363        let path = format!("/tmp/rt_test_pss_{}", std::process::id());
1364
1365        let (pss_before, rss_before) = process_tree_memory_mib(&[pid]);
1366
1367        // Write a temp file that this process will map read-only.
1368        {
1369            let mut f = fs::File::create(&path).expect("cannot create temp file for T-CPU-18");
1370            let chunk = vec![0xABu8; 64 * 1024];
1371            for _ in 0..(MAPPING_SIZE / chunk.len()) {
1372                f.write_all(&chunk).expect("write failed");
1373            }
1374        }
1375
1376        let file = fs::File::open(&path).expect("cannot open temp file for T-CPU-18");
1377        let ptr = unsafe {
1378            libc::mmap(
1379                std::ptr::null_mut(),
1380                MAPPING_SIZE,
1381                libc::PROT_READ,
1382                libc::MAP_PRIVATE,
1383                file.as_raw_fd(),
1384                0,
1385            )
1386        };
1387        assert_ne!(ptr, libc::MAP_FAILED, "mmap failed in T-CPU-18");
1388
1389        // Touch every page to bring all pages into physical RAM (RSS and PSS).
1390        let slice = unsafe { std::slice::from_raw_parts(ptr as *const u8, MAPPING_SIZE) };
1391        let mut checksum = 0u64;
1392        for offset in (0..MAPPING_SIZE).step_by(4096) {
1393            checksum = checksum.wrapping_add(u64::from(slice[offset]));
1394        }
1395        let _ = checksum;
1396
1397        let (pss_after, rss_after) = process_tree_memory_mib(&[pid]);
1398
1399        // Clean up before asserting so a failure does not leak resources.
1400        unsafe { libc::munmap(ptr, MAPPING_SIZE) };
1401        fs::remove_file(&path).ok();
1402
1403        let pss_delta = pss_after.saturating_sub(pss_before);
1404        let rss_delta = rss_after.saturating_sub(rss_before);
1405
1406        // process_tree_memory_mib truncates bytes->KiB->MiB twice, so each
1407        // reading can lose up to ~1 MiB. Allow 1 MiB of slack in deltas and
1408        // 2 MiB in the pss/rss skew so that ARM runners (where actual deltas
1409        // land just under the integer boundary) do not produce false failures.
1410        const TRUNC_SLACK_MIB: u64 = 1;
1411
1412        assert!(
1413            rss_delta + TRUNC_SLACK_MIB >= MAPPING_MIB as u64,
1414            "RSS must increase by >= {MAPPING_MIB} MiB after touching the mapping: \
1415             before={rss_before} MiB, after={rss_after} MiB (delta={rss_delta} MiB)"
1416        );
1417        assert!(
1418            pss_delta + TRUNC_SLACK_MIB >= MAPPING_MIB as u64,
1419            "PSS must increase by >= {MAPPING_MIB} MiB as sole mapper of the file: \
1420             before={pss_before} MiB, after={pss_after} MiB (delta={pss_delta} MiB)"
1421        );
1422        assert!(
1423            pss_after <= rss_after + TRUNC_SLACK_MIB,
1424            "PSS ({pss_after} MiB) must not exceed RSS ({rss_after} MiB)"
1425        );
1426        // For the sole mapper the PSS delta and RSS delta must agree within 2 MiB.
1427        // A regression that breaks smaps_rollup reading (e.g. returning 0 for PSS)
1428        // would leave pss_delta == 0 while rss_delta >= MAPPING_MIB.
1429        let skew = pss_delta.abs_diff(rss_delta);
1430        assert!(
1431            skew <= 1 + TRUNC_SLACK_MIB,
1432            "PSS delta ({pss_delta} MiB) and RSS delta ({rss_delta} MiB) must agree within \
1433             2 MiB for a sole mapper -- larger skew indicates smaps_rollup is not being read"
1434        );
1435    }
1436
1437    // T-CPU-18a: documents the arithmetic behind TRUNC_SLACK_MIB.
1438    //
1439    // process_tree_memory_mib divides bytes -> KiB -> MiB with truncating
1440    // integer division twice.  Each truncation discards up to 1023 KiB, so
1441    // the delta of two truncated readings can appear ~1 MiB smaller than
1442    // the real increase.  This is what caused test_pss_tracks_file_backed_mapping
1443    // to fail on ARM runners: actual PSS delta ~3.998 MiB was reported as 3 MiB.
1444    #[test]
1445    fn test_mib_truncation_can_underreport_pss_delta() {
1446        // Scenario: PSS goes from 8.001 MiB to 11.999 MiB (real delta ~3.998 MiB).
1447        let pss_before_bytes: u64 = (8 * 1024 + 1) * 1024; // 8.001 MiB
1448        let pss_after_bytes: u64 = (12 * 1024 - 1) * 1024; // 11.999 MiB
1449
1450        let before_mib = (pss_before_bytes / 1024) / 1024; // 8
1451        let after_mib = (pss_after_bytes / 1024) / 1024; // 11
1452        let delta_mib = after_mib.saturating_sub(before_mib); // 3
1453
1454        assert_eq!(before_mib, 8);
1455        assert_eq!(after_mib, 11);
1456        assert_eq!(
1457            delta_mib, 3,
1458            "truncation makes ~4 MiB delta appear as 3 MiB"
1459        );
1460
1461        // Without slack the T-CPU-18 assertion `delta >= 4` would fail on ARM.
1462        assert!(delta_mib < 4);
1463
1464        // With TRUNC_SLACK_MIB = 1 the assertion recovers.
1465        const TRUNC_SLACK_MIB: u64 = 1;
1466        assert!(delta_mib + TRUNC_SLACK_MIB >= 4);
1467    }
1468
1469    // Reads PSS for one process in KiB directly from smaps_rollup (bytes / 1024),
1470    // bypassing the second /1024 truncation that process_tree_memory_mib applies.
1471    fn read_pss_kib(pid: i32) -> u64 {
1472        let proc_ = procfs::process::Process::new(pid).expect("process not found");
1473        proc_
1474            .smaps_rollup()
1475            .expect("smaps_rollup unavailable")
1476            .memory_map_rollup
1477            .iter()
1478            .find_map(|m| m.extension.map.get("Pss").copied())
1479            .unwrap_or(0)
1480            / 1024
1481    }
1482
1483    // T-CPU-18b: same file-backed mapping scenario as T-CPU-18 but measured in
1484    // KiB via read_pss_kib, which avoids the MiB truncation entirely.  The
1485    // assertion can therefore be tight (64 KiB slack covers page-size quantization
1486    // on kernels with page sizes larger than 4 KiB, e.g. 16 KiB or 64 KiB ARM).
1487    #[test]
1488    fn test_pss_tracks_file_backed_mapping_kib_resolution() {
1489        use std::fs;
1490        use std::io::Write as _;
1491        use std::os::unix::io::AsRawFd;
1492
1493        const MAPPING_MIB: usize = 4;
1494        const MAPPING_SIZE: usize = MAPPING_MIB * 1024 * 1024;
1495        const EXPECTED_DELTA_KIB: u64 = (MAPPING_MIB * 1024) as u64;
1496        const SLACK_KIB: u64 = 64;
1497
1498        let pid = i32::try_from(std::process::id()).expect("PID too large");
1499        let path = format!("/tmp/rt_test_pss_kib_{}", std::process::id());
1500
1501        let pss_before_kib = read_pss_kib(pid);
1502
1503        {
1504            let mut f = fs::File::create(&path).expect("cannot create temp file for T-CPU-18b");
1505            let chunk = vec![0xCDu8; 64 * 1024];
1506            for _ in 0..(MAPPING_SIZE / chunk.len()) {
1507                f.write_all(&chunk).expect("write failed");
1508            }
1509        }
1510
1511        let file = fs::File::open(&path).expect("cannot open temp file for T-CPU-18b");
1512        let ptr = unsafe {
1513            libc::mmap(
1514                std::ptr::null_mut(),
1515                MAPPING_SIZE,
1516                libc::PROT_READ,
1517                libc::MAP_PRIVATE,
1518                file.as_raw_fd(),
1519                0,
1520            )
1521        };
1522        assert_ne!(ptr, libc::MAP_FAILED, "mmap failed in T-CPU-18b");
1523
1524        let slice = unsafe { std::slice::from_raw_parts(ptr as *const u8, MAPPING_SIZE) };
1525        let mut checksum = 0u64;
1526        for offset in (0..MAPPING_SIZE).step_by(4096) {
1527            checksum = checksum.wrapping_add(u64::from(slice[offset]));
1528        }
1529        let _ = checksum;
1530
1531        let pss_after_kib = read_pss_kib(pid);
1532
1533        unsafe { libc::munmap(ptr, MAPPING_SIZE) };
1534        fs::remove_file(&path).ok();
1535
1536        let pss_delta_kib = pss_after_kib.saturating_sub(pss_before_kib);
1537
1538        assert!(
1539            pss_delta_kib + SLACK_KIB >= EXPECTED_DELTA_KIB,
1540            "PSS must increase by >= {EXPECTED_DELTA_KIB} KiB (±{SLACK_KIB} KiB) as sole \
1541             mapper: before={pss_before_kib} KiB, after={pss_after_kib} KiB \
1542             (delta={pss_delta_kib} KiB)"
1543        );
1544    }
1545
1546    // -----------------------------------------------------------------------
1547    // Transient /proc scan failure: correction skip + carry-forward
1548    // -----------------------------------------------------------------------
1549
1550    // T-CPU-19: cutime correction is skipped when exited ticks exceed the
1551    // raw delta, preventing artificial zero values from transient /proc
1552    // scan failures where a child's stat() read fails but the parent's
1553    // cutime did not actually increase.
1554    #[test]
1555    fn test_cutime_correction_skipped_when_exited_exceeds_raw() {
1556        let prev: HashMap<i32, (u64, u64)> =
1557            [(1, (500, 0)), (2, (50000, 0))].iter().cloned().collect();
1558
1559        let curr: HashMap<i32, (u64, u64)> = [(1, (600, 0))].iter().cloned().collect();
1560
1561        let raw: u64 = curr
1562            .iter()
1563            .map(|(pid, &(cu, cs))| {
1564                let (pu, ps) = prev.get(pid).copied().unwrap_or((cu, cs));
1565                cu.saturating_sub(pu) + cs.saturating_sub(ps)
1566            })
1567            .sum();
1568        assert_eq!(raw, 100, "raw delta is parent's own 100 ticks");
1569
1570        let exited: u64 = prev
1571            .iter()
1572            .filter(|(pid, _)| !curr.contains_key(pid))
1573            .map(|(_, &(pu, ps))| pu + ps)
1574            .sum();
1575        assert_eq!(exited, 50000);
1576
1577        // Old behavior: raw.saturating_sub(exited) = 0 (the bug).
1578        assert_eq!(raw.saturating_sub(exited), 0);
1579
1580        // New behavior: skip correction when exited > raw.
1581        let corrected = if exited <= raw { raw - exited } else { raw };
1582        assert_eq!(
1583            corrected, 100,
1584            "must preserve raw delta when correction is implausible"
1585        );
1586    }
1587
1588    // T-CPU-20: carry-forward preserves prev entries for missing PIDs so
1589    // that a reappearing PID computes a correct delta spanning the gap
1590    // rather than being treated as "new" (delta = 0).
1591    #[test]
1592    fn test_carry_forward_spans_gap_for_reappearing_pid() {
1593        let prev: HashMap<i32, (u64, u64)> =
1594            [(1, (500, 0)), (2, (10000, 0))].iter().cloned().collect();
1595
1596        // Simulate carry-forward: child was in prev but missing from live scan.
1597        let mut stored_prev: HashMap<i32, (u64, u64)> = [(1, (600, 0))].iter().cloned().collect();
1598        for (&pid, &ticks) in &prev {
1599            stored_prev.entry(pid).or_insert(ticks);
1600        }
1601        assert_eq!(
1602            stored_prev.get(&2),
1603            Some(&(10000, 0)),
1604            "child must be carried forward with prev ticks"
1605        );
1606
1607        // Child reappears with 11000 ticks (earned 1000 during the gap).
1608        let curr: HashMap<i32, (u64, u64)> =
1609            [(1, (700, 0)), (2, (11000, 0))].iter().cloned().collect();
1610
1611        let delta_with_cf: u64 = curr
1612            .iter()
1613            .map(|(pid, &(cu, cs))| {
1614                let (pu, ps) = stored_prev.get(pid).copied().unwrap_or((cu, cs));
1615                cu.saturating_sub(pu) + cs.saturating_sub(ps)
1616            })
1617            .sum();
1618        assert_eq!(
1619            delta_with_cf, 1100,
1620            "with carry-forward: parent delta (100) + child delta spanning gap (1000)"
1621        );
1622
1623        // Without carry-forward: child treated as new (pu = cu), delta = 0.
1624        let no_cf_prev: HashMap<i32, (u64, u64)> = [(1, (600, 0))].iter().cloned().collect();
1625        let delta_without_cf: u64 = curr
1626            .iter()
1627            .map(|(pid, &(cu, cs))| {
1628                let (pu, ps) = no_cf_prev.get(pid).copied().unwrap_or((cu, cs));
1629                cu.saturating_sub(pu) + cs.saturating_sub(ps)
1630            })
1631            .sum();
1632        assert_eq!(
1633            delta_without_cf, 100,
1634            "without carry-forward: only parent delta (100), child contribution lost"
1635        );
1636    }
1637
1638    // T-CPU-21: carry-forward is limited to one hop — a PID carried forward
1639    // in interval N is NOT carried forward again in interval N+1.  This
1640    // prevents dead PIDs from accumulating indefinitely.
1641    #[test]
1642    fn test_carry_forward_limited_to_one_hop() {
1643        let mut carried_forward: HashSet<i32> = HashSet::new();
1644
1645        // Interval N: child 2 missing from live scan. Not in carried_forward.
1646        let prev_ticks: HashMap<i32, (u64, u64)> =
1647            [(1, (500, 0)), (2, (10000, 0))].iter().cloned().collect();
1648        let mut curr_ticks: HashMap<i32, (u64, u64)> = [(1, (600, 0))].iter().cloned().collect();
1649
1650        let mut new_carried = HashSet::new();
1651        for (&pid, &ticks) in &prev_ticks {
1652            if !curr_ticks.contains_key(&pid) && !carried_forward.contains(&pid) {
1653                curr_ticks.insert(pid, ticks);
1654                new_carried.insert(pid);
1655            }
1656        }
1657        carried_forward = new_carried;
1658
1659        assert!(
1660            curr_ticks.contains_key(&2),
1661            "child must be carried forward in interval N"
1662        );
1663        assert!(
1664            carried_forward.contains(&2),
1665            "child must be in the carried-forward set"
1666        );
1667
1668        // Interval N+1: child 2 still missing. Already in carried_forward.
1669        let prev_ticks_n1 = curr_ticks.clone();
1670        let mut curr_ticks_n1: HashMap<i32, (u64, u64)> = [(1, (700, 0))].iter().cloned().collect();
1671
1672        let mut new_carried_n1 = HashSet::new();
1673        for (&pid, &ticks) in &prev_ticks_n1 {
1674            if !curr_ticks_n1.contains_key(&pid) && !carried_forward.contains(&pid) {
1675                curr_ticks_n1.insert(pid, ticks);
1676                new_carried_n1.insert(pid);
1677            }
1678        }
1679
1680        assert!(
1681            !curr_ticks_n1.contains_key(&2),
1682            "child must NOT be carried forward a second time"
1683        );
1684        assert!(
1685            !new_carried_n1.contains(&2),
1686            "child must NOT be in the new carried-forward set"
1687        );
1688    }
1689}