lesavka/common/src/process_metrics.rs

106 lines
3.3 KiB
Rust
Raw Normal View History

#![forbid(unsafe_code)]
use std::time::Instant;
2026-04-17 11:54:36 -03:00
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<f32> {
let now = Instant::now();
let runtime_ns = read_process_runtime_ns()?;
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)
}
pub fn sample_tenths_percent(&mut self) -> Option<u32> {
self.sample_percent()
.map(|pct| (pct * 10.0).clamp(0.0, u32::MAX as f32) as u32)
}
}
fn read_process_runtime_ns() -> Option<u64> {
2026-04-17 11:54:36 -03:00
read_process_runtime_from_stat().or_else(read_process_runtime_from_schedstat)
}
fn read_process_runtime_from_stat() -> Option<u64> {
let text = std::fs::read_to_string("/proc/self/stat").ok()?;
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::<u64>().ok()?;
let stime_ticks = fields.get(12)?.parse::<u64>().ok()?;
let total_ticks = utime_ticks.saturating_add(stime_ticks);
let ticks_per_second = *clock_ticks_per_second()?;
Some(total_ticks.saturating_mul(1_000_000_000) / ticks_per_second.max(1))
}
fn read_process_runtime_from_schedstat() -> Option<u64> {
let text = std::fs::read_to_string("/proc/self/schedstat").ok()?;
text.split_whitespace().next()?.parse::<u64>().ok()
}
2026-04-17 11:54:36 -03:00
fn clock_ticks_per_second() -> Option<&'static u64> {
static CLOCK_TICKS_PER_SECOND: OnceLock<Option<u64>> = 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::<u64>()
.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());
}
2026-04-17 11:54:36 -03:00
#[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();
}
}