2026-04-17 06:14:54 -03:00
|
|
|
#![forbid(unsafe_code)]
|
|
|
|
|
|
|
|
|
|
use std::time::Instant;
|
2026-04-17 11:54:36 -03:00
|
|
|
use std::{process::Command, sync::OnceLock};
|
2026-04-17 06:14:54 -03:00
|
|
|
|
|
|
|
|
/// 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()?;
|
2026-04-23 03:49:49 -03:00
|
|
|
self.sample_percent_at(now, runtime_ns)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 sample_percent_at(&mut self, now: Instant, runtime_ns: u64) -> Option<f32> {
|
2026-04-17 06:14:54 -03:00
|
|
|
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<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()?;
|
2026-04-23 03:49:49 -03:00
|
|
|
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<u64> {
|
2026-04-17 11:54:36 -03:00
|
|
|
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);
|
|
|
|
|
Some(total_ticks.saturating_mul(1_000_000_000) / ticks_per_second.max(1))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn read_process_runtime_from_schedstat() -> Option<u64> {
|
2026-04-17 06:14:54 -03:00
|
|
|
let text = std::fs::read_to_string("/proc/self/schedstat").ok()?;
|
2026-04-23 03:49:49 -03:00
|
|
|
parse_schedstat_runtime_ns(&text)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_schedstat_runtime_ns(text: &str) -> Option<u64> {
|
2026-04-17 06:14:54 -03:00
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 06:14:54 -03:00
|
|
|
#[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-23 03:49:49 -03:00
|
|
|
#[test]
|
|
|
|
|
fn schedstat_fallback_reader_is_non_panicking() {
|
|
|
|
|
let _ = read_process_runtime_from_schedstat();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 11:54:36 -03:00
|
|
|
#[test]
|
|
|
|
|
fn clock_tick_lookup_reads() {
|
|
|
|
|
assert!(clock_ticks_per_second().is_some());
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 06:14:54 -03:00
|
|
|
#[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();
|
|
|
|
|
}
|
2026-04-23 03:49:49 -03:00
|
|
|
|
|
|
|
|
#[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)
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-17 06:14:54 -03:00
|
|
|
}
|