Skip to main content

concinnity_engine/app/
syscpu.rs

1// src/app/syscpu.rs
2//
3// Host-CPU queries, the sibling of `app::sysmem`. One value: the CPU time this
4// process has burned across all its threads. Utilization is a rate, not a
5// reading, so `CpuSampler` turns successive queries into one.
6//
7// Deliberately a small hand-rolled platform shim rather than a dependency like
8// `sysinfo`, matching sysmem: one syscall per platform, `None` when the
9// platform call is unavailable or fails, and callers degrade to "unknown".
10
11use std::time::{Duration, Instant};
12
13// Total CPU time consumed by this process since it started, summed over every
14// thread and over user + kernel time. `None` if the platform query is
15// unsupported or fails.
16pub(crate) fn process_cpu_time() -> Option<Duration> {
17    imp::process_cpu_time()
18}
19
20/// Turns successive `process_cpu_time` readings into a utilization rate.
21///
22/// The unit is cores: 1.0 means one core saturated for the whole interval, 4.0
23/// means four. It is deliberately not a percentage, because the useful
24/// comparison is against `ThreadBudget::total_cores` rather than against 100.
25#[derive(Debug, Default)]
26pub struct CpuSampler {
27    last: Option<(Duration, Instant)>,
28}
29
30impl CpuSampler {
31    /// A sampler with no prior reading.
32    pub fn new() -> Self {
33        Self { last: None }
34    }
35
36    /// Samples the process clock now. `None` on the first call (a rate needs two
37    /// readings), when the platform query fails, or when no time has passed.
38    pub fn sample(&mut self) -> Option<f32> {
39        self.fold(process_cpu_time()?, Instant::now())
40    }
41
42    // The rate itself, split out from the clock reads so it can be tested
43    // against known intervals.
44    fn fold(&mut self, cpu: Duration, at: Instant) -> Option<f32> {
45        let rate = match self.last {
46            Some((last_cpu, last_at)) => {
47                let wall = at.saturating_duration_since(last_at).as_secs_f64();
48                // Two samples in the same instant have no rate to report; the
49                // stored reading stays put so the next one spans a real gap.
50                if wall <= 0.0 {
51                    return None;
52                }
53                // CPU time is monotonic, but a failed platform read must not be
54                // able to produce a negative rate.
55                let busy = cpu.saturating_sub(last_cpu).as_secs_f64();
56                Some((busy / wall) as f32)
57            }
58            None => None,
59        };
60        self.last = Some((cpu, at));
61        rate
62    }
63}
64
65#[cfg(unix)]
66mod imp {
67    use std::time::Duration;
68
69    // getrusage(RUSAGE_SELF) sums user + kernel time over every thread in the
70    // process on both macOS and Linux, so the two share one implementation.
71    pub(super) fn process_cpu_time() -> Option<Duration> {
72        // SAFETY: `rusage` is a plain C struct of integer fields, so all-zero
73        // is a valid inhabitant.
74        let mut usage: libc::rusage = unsafe { std::mem::zeroed() };
75        // SAFETY: `usage` is a correctly sized rusage output buffer and
76        // RUSAGE_SELF is a valid `who` value.
77        let rc = unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut usage) };
78        (rc == 0).then(|| timeval(usage.ru_utime) + timeval(usage.ru_stime))
79    }
80
81    fn timeval(t: libc::timeval) -> Duration {
82        let secs = t.tv_sec.max(0) as u64;
83        let micros = t.tv_usec.clamp(0, 999_999) as u32;
84        Duration::new(secs, micros * 1_000)
85    }
86}
87
88#[cfg(windows)]
89mod imp {
90    use std::time::Duration;
91    use windows::Win32::Foundation::FILETIME;
92    use windows::Win32::System::Threading::{GetCurrentProcess, GetProcessTimes};
93
94    // GetProcessTimes reports kernel + user time across every thread. The
95    // creation/exit times come back in the same call and are unused.
96    pub(super) fn process_cpu_time() -> Option<Duration> {
97        let mut creation = FILETIME::default();
98        let mut exit = FILETIME::default();
99        let mut kernel = FILETIME::default();
100        let mut user = FILETIME::default();
101        // SAFETY: all four out-params are valid FILETIME buffers, as the API
102        // requires; the current-process pseudo-handle needs no close.
103        unsafe {
104            GetProcessTimes(
105                GetCurrentProcess(),
106                &mut creation,
107                &mut exit,
108                &mut kernel,
109                &mut user,
110            )
111        }
112        .ok()?;
113        Some(filetime(kernel) + filetime(user))
114    }
115
116    // FILETIME counts 100-nanosecond ticks.
117    fn filetime(ft: FILETIME) -> Duration {
118        let ticks = ((ft.dwHighDateTime as u64) << 32) | ft.dwLowDateTime as u64;
119        Duration::from_nanos(ticks.saturating_mul(100))
120    }
121}
122
123#[cfg(not(any(unix, windows)))]
124mod imp {
125    use std::time::Duration;
126
127    pub(super) fn process_cpu_time() -> Option<Duration> {
128        None
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    // On a supported platform the query must return a clock that advances as the
137    // process burns CPU. A single reading can land inside one scheduler tick
138    // (Windows `GetProcessTimes` is ~15ms granular), so spin until it moves
139    // rather than assuming any time has accumulated yet.
140    #[test]
141    fn query_returns_a_plausible_value() {
142        if !cfg!(any(unix, windows)) {
143            return;
144        }
145
146        let start = process_cpu_time().expect("CPU query works on this platform");
147
148        // Burn CPU until the reading advances, bounded so a broken query fails
149        // the test instead of hanging it.
150        let deadline = Instant::now() + Duration::from_secs(5);
151        let mut work: u64 = 0;
152        let latest = loop {
153            for _ in 0..4096 {
154                work = work.wrapping_mul(2_654_435_761).wrapping_add(1);
155            }
156            let now = process_cpu_time().expect("CPU query keeps working");
157            assert!(now >= start, "process CPU time must be non-decreasing");
158            if now > start || Instant::now() >= deadline {
159                break now;
160            }
161        };
162        std::hint::black_box(work);
163
164        assert!(
165            latest > start,
166            "process CPU time should advance while the test burns CPU"
167        );
168    }
169
170    // A rate needs two readings, so the first sample only primes the sampler.
171    #[test]
172    fn first_fold_reports_no_rate() {
173        let mut sampler = CpuSampler::new();
174        assert_eq!(sampler.fold(Duration::from_secs(1), Instant::now()), None);
175    }
176
177    // One core busy for half of a two-second interval is 0.5 cores.
178    #[test]
179    fn fold_reports_cpu_time_over_wall_time() {
180        let t0 = Instant::now();
181        let mut sampler = CpuSampler::new();
182        sampler.fold(Duration::ZERO, t0);
183
184        let rate = sampler
185            .fold(Duration::from_secs(1), t0 + Duration::from_secs(2))
186            .expect("second fold spans a real interval");
187        assert!((rate - 0.5).abs() < 1e-5, "expected 0.5 cores, got {rate}");
188    }
189
190    // Cores are not capped at one: four cores saturated for an interval reads
191    // as 4.0, which is what makes the value comparable to the core count.
192    #[test]
193    fn fold_reports_more_than_one_core() {
194        let t0 = Instant::now();
195        let mut sampler = CpuSampler::new();
196        sampler.fold(Duration::ZERO, t0);
197
198        let rate = sampler
199            .fold(Duration::from_secs(4), t0 + Duration::from_secs(1))
200            .expect("second fold spans a real interval");
201        assert!((rate - 4.0).abs() < 1e-5, "expected 4.0 cores, got {rate}");
202    }
203
204    // Successive folds each measure their own interval rather than accumulating
205    // against the first reading.
206    #[test]
207    fn fold_measures_each_interval_independently() {
208        let t0 = Instant::now();
209        let mut sampler = CpuSampler::new();
210        sampler.fold(Duration::ZERO, t0);
211        sampler.fold(Duration::from_secs(1), t0 + Duration::from_secs(1));
212
213        let rate = sampler
214            .fold(Duration::from_millis(1500), t0 + Duration::from_secs(2))
215            .expect("third fold spans a real interval");
216        assert!((rate - 0.5).abs() < 1e-5, "expected 0.5 cores, got {rate}");
217    }
218
219    // Two reads in the same instant have no interval to divide by.
220    #[test]
221    fn fold_with_no_elapsed_time_reports_no_rate() {
222        let t0 = Instant::now();
223        let mut sampler = CpuSampler::new();
224        sampler.fold(Duration::ZERO, t0);
225        assert_eq!(sampler.fold(Duration::from_secs(1), t0), None);
226    }
227}