#![forbid(unsafe_code)] use std::time::Instant; use std::{process::Command, sync::OnceLock}; /// Sample per-process CPU pressure from Linux procfs. /// /// Inputs: none when constructed; call `sample_percent()` over time. /// Outputs: percentage of one CPU core used by the current process over the /// elapsed sampling window. /// Why: this gives the launcher and server lightweight pressure telemetry /// without adding a heavyweight system-information dependency. #[derive(Debug, Clone, Default)] pub struct ProcessCpuSampler { last: Option<(Instant, u64)>, } impl ProcessCpuSampler { #[must_use] pub fn new() -> Self { Self::default() } pub fn sample_percent(&mut self) -> Option { let now = Instant::now(); let runtime_ns = read_process_runtime_ns()?; self.sample_percent_at(now, runtime_ns) } pub fn sample_tenths_percent(&mut self) -> Option { self.sample_percent() .map(|pct| (pct * 10.0).clamp(0.0, u32::MAX as f32) as u32) } fn sample_percent_at(&mut self, now: Instant, runtime_ns: u64) -> Option { let previous = self.last.replace((now, runtime_ns))?; let elapsed_ns = now .saturating_duration_since(previous.0) .as_nanos() .min(u128::from(u64::MAX)) as u64; if elapsed_ns == 0 || runtime_ns < previous.1 { return None; } Some(runtime_ns.saturating_sub(previous.1) as f32 * 100.0 / elapsed_ns as f32) } } fn read_process_runtime_ns() -> Option { read_process_runtime_from_stat().or_else(read_process_runtime_from_schedstat) } fn read_process_runtime_from_stat() -> Option { let text = std::fs::read_to_string("/proc/self/stat").ok()?; let ticks_per_second = *clock_ticks_per_second()?; parse_stat_runtime_ns(&text, ticks_per_second) } fn parse_stat_runtime_ns(text: &str, ticks_per_second: u64) -> Option { let close = text.rfind(')')?; let rest = text.get(close + 2..)?; let fields: Vec<&str> = rest.split_whitespace().collect(); let utime_ticks = fields.get(11)?.parse::().ok()?; let stime_ticks = fields.get(12)?.parse::().ok()?; let total_ticks = utime_ticks.saturating_add(stime_ticks); Some(total_ticks.saturating_mul(1_000_000_000) / ticks_per_second.max(1)) } fn read_process_runtime_from_schedstat() -> Option { let text = std::fs::read_to_string("/proc/self/schedstat").ok()?; parse_schedstat_runtime_ns(&text) } fn parse_schedstat_runtime_ns(text: &str) -> Option { text.split_whitespace().next()?.parse::().ok() } fn clock_ticks_per_second() -> Option<&'static u64> { static CLOCK_TICKS_PER_SECOND: OnceLock> = OnceLock::new(); CLOCK_TICKS_PER_SECOND .get_or_init(|| { let output = Command::new("getconf").arg("CLK_TCK").output().ok()?; if !output.status.success() { return None; } String::from_utf8(output.stdout) .ok()? .trim() .parse::() .ok() }) .as_ref() } #[cfg(test)] mod tests { use super::*; use std::thread; use std::time::Duration; #[test] fn schedstat_runtime_reads() { assert!(read_process_runtime_ns().is_some()); } #[test] fn schedstat_fallback_reader_is_non_panicking() { let _ = read_process_runtime_from_schedstat(); } #[test] fn clock_tick_lookup_reads() { assert!(clock_ticks_per_second().is_some()); } #[test] fn sampler_returns_percentage_after_two_samples() { let mut sampler = ProcessCpuSampler::new(); assert!(sampler.sample_percent().is_none()); thread::sleep(Duration::from_millis(10)); let _ = sampler.sample_percent(); } #[test] fn sampler_handles_manual_runtime_edges() { let mut sampler = ProcessCpuSampler::new(); let now = Instant::now(); assert!(sampler.sample_percent_at(now, 1_000).is_none()); assert_eq!( sampler .sample_percent_at(now + Duration::from_millis(10), 2_000) .expect("percentage"), 0.01 ); assert!( sampler .sample_percent_at(now + Duration::from_millis(20), 1_500) .is_none() ); assert!( sampler .sample_percent_at(now + Duration::from_millis(20), 1_500) .is_none() ); } #[test] fn procfs_parsers_handle_valid_and_malformed_samples() { let stat = "123 (lesavka worker) S 1 2 3 4 5 6 7 8 9 10 11 12 13 21 17"; assert_eq!(parse_stat_runtime_ns(stat, 100), Some(230_000_000)); assert_eq!(parse_stat_runtime_ns("missing close paren", 100), None); assert_eq!(parse_stat_runtime_ns("123 (name) S too short", 100), None); assert_eq!(parse_schedstat_runtime_ns("12345 678 9"), Some(12345)); assert_eq!(parse_schedstat_runtime_ns("not-a-number 678"), None); assert_eq!(parse_schedstat_runtime_ns(""), None); } #[test] fn tenths_percent_is_clamped_to_integer_range() { let mut sampler = ProcessCpuSampler::new(); let now = Instant::now(); assert!(sampler.sample_percent_at(now, 0).is_none()); assert_eq!( sampler .sample_percent_at(now + Duration::from_millis(1), u64::MAX) .map(|pct| (pct * 10.0).clamp(0.0, u32::MAX as f32) as u32), Some(u32::MAX) ); } }