use std::io;
use std::time::Duration;
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct ProcessUsage {
pub resident_bytes: u64,
pub footprint_bytes: u64,
pub cpu_seconds: f64,
pub wakeups_total: Option<u64>,
pub disk_ops_total: Option<u64>,
pub disk_read_bytes: Option<u64>,
pub disk_write_bytes: Option<u64>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct UsageRates {
pub cpu_percent: f64,
pub wakeups_per_second: Option<f64>,
pub disk_ops_per_second: Option<f64>,
pub disk_read_bytes_per_second: Option<f64>,
pub disk_write_bytes_per_second: Option<f64>,
}
impl ProcessUsage {
pub fn rates_since(&self, earlier: &ProcessUsage, elapsed: Duration) -> UsageRates {
let secs = elapsed.as_secs_f64();
let rate = |now: Option<u64>, then: Option<u64>| match (now, then) {
(Some(now), Some(then)) if secs > 0.0 => Some(now.saturating_sub(then) as f64 / secs),
(Some(_), Some(_)) => Some(0.0),
_ => None,
};
UsageRates {
cpu_percent: if secs > 0.0 {
(self.cpu_seconds - earlier.cpu_seconds).max(0.0) / secs * 100.0
} else {
0.0
},
wakeups_per_second: rate(self.wakeups_total, earlier.wakeups_total),
disk_ops_per_second: rate(self.disk_ops_total, earlier.disk_ops_total),
disk_read_bytes_per_second: rate(self.disk_read_bytes, earlier.disk_read_bytes),
disk_write_bytes_per_second: rate(self.disk_write_bytes, earlier.disk_write_bytes),
}
}
}
pub fn process_usage() -> io::Result<ProcessUsage> {
imp::sample()
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct TreeUsage {
pub processes: usize,
pub resident_bytes: u64,
pub unreadable: usize,
}
pub fn tree_usage(root: &crate::AdoptedIdentity) -> io::Result<TreeUsage> {
if root.verify()? != crate::IdentityCheck::Same {
return Ok(TreeUsage::default());
}
let mut members = vec![root.clone()];
members.extend(root.descendants()?);
let table = crate::process_table()?;
let mut usage = TreeUsage::default();
for member in &members {
if member.verify()? != crate::IdentityCheck::Same {
continue;
}
usage.processes += 1;
match table
.iter()
.find(|entry| entry.pid == member.pid())
.and_then(|entry| entry.working_set_bytes)
{
Some(bytes) => usage.resident_bytes = usage.resident_bytes.saturating_add(bytes),
None => usage.unreadable += 1,
}
}
Ok(usage)
}
#[derive(Clone, Copy, Debug)]
pub struct IdleObservation {
pub elapsed: Duration,
pub start: ProcessUsage,
pub end: ProcessUsage,
pub rates: UsageRates,
}
pub fn observe_idle(duration: Duration) -> io::Result<IdleObservation> {
let start = process_usage()?;
let began = std::time::Instant::now();
std::thread::sleep(duration);
let end = process_usage()?;
let elapsed = began.elapsed();
Ok(IdleObservation {
elapsed,
start,
end,
rates: end.rates_since(&start, elapsed),
})
}
#[derive(Clone, Copy, Debug)]
pub struct BudgetLabels<'a> {
pub krate: &'a str,
pub version: &'a str,
pub scale_unit: &'a str,
pub scale: u64,
pub what: &'a str,
pub scope: &'a str,
}
impl IdleObservation {
pub fn result_lines(&self, warm: &ProcessUsage, labels: &BudgetLabels<'_>) -> String {
let BudgetLabels {
krate,
version,
scale_unit,
scale,
what,
scope,
} = *labels;
let mut out = String::new();
let mut line = |text: String| {
out.push_str("RESULT ");
out.push_str(&text);
out.push('\n');
};
let resident_max = warm.resident_bytes.max(self.end.resident_bytes);
line(format!("warm_resident_bytes={} bytes", warm.resident_bytes));
line(format!(
"idle_resident_bytes={} bytes",
self.end.resident_bytes
));
if resident_max > 0 {
line(format!("resident_memory_bytes={resident_max} bytes"));
} else {
line("resident_memory_bytes=unavailable host reported a zero resident set".to_string());
}
line(format!(
"footprint_bytes={} bytes",
warm.footprint_bytes.max(self.end.footprint_bytes)
));
line(format!(
"idle_window_seconds={:.1} seconds",
self.elapsed.as_secs_f64()
));
line(format!(
"idle_cpu_percent={:.3} percent",
self.rates.cpu_percent
));
let opt = |name: &str,
value: Option<f64>,
unit: &str,
reason: &str,
line: &mut dyn FnMut(String)| match value {
Some(v) => line(format!("{name}={v:.3} {unit}")),
None => line(format!("{name}=unavailable {reason}")),
};
opt(
"wakeups_per_second",
self.rates.wakeups_per_second,
"per_second",
"the host exposes no process wake-up counter",
&mut line,
);
opt(
"disk_ops_per_second",
self.rates.disk_ops_per_second,
"per_second",
"the host exposes no process disk-operation counter",
&mut line,
);
opt(
"disk_read_bytes_per_second",
self.rates.disk_read_bytes_per_second,
"bytes_per_second",
"the host exposes no process disk-byte counter",
&mut line,
);
opt(
"disk_write_bytes_per_second",
self.rates.disk_write_bytes_per_second,
"bytes_per_second",
"the host exposes no process disk-byte counter",
&mut line,
);
line(format!("scale={scale} {what}"));
line(format!("scope={scope}"));
let num =
|v: Option<f64>| v.map_or_else(|| "unavailable".to_string(), |v| format!("{v:.3}"));
let resident = if resident_max > 0 {
resident_max.to_string()
} else {
"unavailable".to_string()
};
line(format!(
"budget crate={krate} version={version} resident_bytes={resident} idle_cpu_percent={:.3} wakeups_per_s={} disk_ops_per_s={} scale_unit={scale_unit} scale_count={scale}",
self.rates.cpu_percent,
num(self.rates.wakeups_per_second),
num(self.rates.disk_ops_per_second),
));
out
}
}
#[cfg(target_os = "macos")]
mod imp {
use super::ProcessUsage;
use std::io;
extern "C" {
fn proc_pid_rusage(pid: i32, flavor: i32, buffer: *mut u8) -> i32;
}
const W_INTERRUPT_WKUPS: usize = 2 + 3;
const W_RESIDENT: usize = 2 + 6;
const W_FOOTPRINT: usize = 2 + 7;
const W_DISK_READ: usize = 2 + 16;
const W_DISK_WRITE: usize = 2 + 17;
pub fn sample() -> io::Result<ProcessUsage> {
let mut words = [0u64; 64];
let rc = unsafe { proc_pid_rusage(libc::getpid(), 2, words.as_mut_ptr().cast()) };
if rc != 0 {
return Err(io::Error::last_os_error());
}
let mut ru: libc::rusage = unsafe { std::mem::zeroed() };
if unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut ru) } != 0 {
return Err(io::Error::last_os_error());
}
let secs = |t: libc::timeval| t.tv_sec as f64 + t.tv_usec as f64 / 1e6;
Ok(ProcessUsage {
resident_bytes: words[W_RESIDENT],
footprint_bytes: words[W_FOOTPRINT],
cpu_seconds: secs(ru.ru_utime) + secs(ru.ru_stime),
wakeups_total: Some(words[W_INTERRUPT_WKUPS]),
disk_ops_total: None,
disk_read_bytes: Some(words[W_DISK_READ]),
disk_write_bytes: Some(words[W_DISK_WRITE]),
})
}
}
#[cfg(all(
unix,
not(target_os = "macos"),
not(any(target_os = "linux", target_os = "android"))
))]
mod imp {
use super::ProcessUsage;
use std::io;
pub fn sample() -> io::Result<ProcessUsage> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"process usage sampling is implemented for macOS, Linux, Android and Windows only",
))
}
}
#[cfg(any(target_os = "linux", target_os = "android"))]
mod imp {
use super::ProcessUsage;
use std::io;
pub fn sample() -> io::Result<ProcessUsage> {
let statm = std::fs::read_to_string("/proc/self/statm")?;
let pages: u64 = statm
.split_whitespace()
.nth(1)
.and_then(|v| v.parse().ok())
.ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidData, "unreadable /proc/self/statm")
})?;
let page = u64::try_from(unsafe { libc::sysconf(libc::_SC_PAGESIZE) }.max(1))
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "page size out of range"))?;
let overflow = || io::Error::new(io::ErrorKind::InvalidData, "memory size overflows u64");
let resident = pages.checked_mul(page).ok_or_else(overflow)?;
let blocks = |count: libc::c_long| u64::try_from(count.max(0)).unwrap_or(0);
let mut ru: libc::rusage = unsafe { std::mem::zeroed() };
if unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut ru) } != 0 {
return Err(io::Error::last_os_error());
}
let secs = |t: libc::timeval| t.tv_sec as f64 + t.tv_usec as f64 / 1e6;
Ok(ProcessUsage {
resident_bytes: resident,
footprint_bytes: resident,
cpu_seconds: secs(ru.ru_utime) + secs(ru.ru_stime),
wakeups_total: None,
disk_ops_total: blocks(ru.ru_inblock).checked_add(blocks(ru.ru_oublock)),
disk_read_bytes: None,
disk_write_bytes: None,
})
}
}
#[cfg(windows)]
mod imp {
use super::ProcessUsage;
use std::io;
use windows::Wdk::System::SystemInformation::{
NtQuerySystemInformation, SystemProcessInformation,
};
use windows::Win32::Foundation::{FILETIME, STATUS_INFO_LENGTH_MISMATCH};
use windows::Win32::System::ProcessStatus::{
GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS, PROCESS_MEMORY_COUNTERS_EX,
};
use windows::Win32::System::Threading::{
GetCurrentProcess, GetCurrentProcessId, GetProcessIoCounters, GetProcessTimes, IO_COUNTERS,
};
use windows::Win32::System::WindowsProgramming::{
SYSTEM_PROCESS_INFORMATION, SYSTEM_THREAD_INFORMATION,
};
fn os(error: windows::core::Error) -> io::Error {
io::Error::from_raw_os_error(error.code().0 & 0xFFFF)
}
fn ticks(t: FILETIME) -> u64 {
((t.dwHighDateTime as u64) << 32) | t.dwLowDateTime as u64
}
#[cfg(target_pointer_width = "64")]
const THREADS_OFFSET: usize = 0x100;
#[cfg(target_pointer_width = "64")]
const MAX_QUERY_BYTES: usize = 256 * 1024 * 1024;
#[cfg(target_pointer_width = "64")]
fn wakeups_total() -> Option<u64> {
let mut words: Vec<u64> = vec![0; (1 << 20) / 8];
let mut length: u32 = 0;
let mut attempts = 0;
loop {
let capacity = words.len() * 8;
let status = unsafe {
NtQuerySystemInformation(
SystemProcessInformation,
words.as_mut_ptr().cast(),
u32::try_from(capacity).ok()?,
&mut length,
)
};
if status.0 >= 0 {
break;
}
attempts += 1;
if status != STATUS_INFO_LENGTH_MISMATCH || attempts > 8 {
return None;
}
let want = (length as usize).checked_add(256 * 1024)?;
if want <= capacity || want > MAX_QUERY_BYTES {
return None;
}
words = vec![0; want.div_ceil(8)];
}
let valid = (length as usize).min(words.len() * 8);
let base = words.as_ptr().cast::<u8>();
let pid = unsafe { GetCurrentProcessId() } as usize;
let thread_size = std::mem::size_of::<SYSTEM_THREAD_INFORMATION>();
let mut offset = 0usize;
loop {
if offset.checked_add(THREADS_OFFSET)? > valid {
return None;
}
let entry = unsafe {
std::ptr::read_unaligned(base.add(offset).cast::<SYSTEM_PROCESS_INFORMATION>())
};
if entry.UniqueProcessId.0 as usize == pid {
let count = entry.NumberOfThreads as usize;
let first = offset + THREADS_OFFSET;
let end = first.checked_add(count.checked_mul(thread_size)?)?;
if end > valid {
return None;
}
let mut total = 0u64;
for i in 0..count {
let thread = unsafe {
std::ptr::read_unaligned(
base.add(first + i * thread_size)
.cast::<SYSTEM_THREAD_INFORMATION>(),
)
};
if thread.ClientId.UniqueProcess.0 as usize != pid {
return None;
}
total = total.checked_add(u64::from(thread.Reserved3))?;
}
return Some(total);
}
let next = entry.NextEntryOffset as usize;
if next == 0 {
return None;
}
offset = offset.checked_add(next)?;
}
}
#[cfg(not(target_pointer_width = "64"))]
fn wakeups_total() -> Option<u64> {
None
}
pub fn sample() -> io::Result<ProcessUsage> {
unsafe {
let process = GetCurrentProcess();
let mut mem = PROCESS_MEMORY_COUNTERS_EX::default();
GetProcessMemoryInfo(
process,
(&mut mem as *mut PROCESS_MEMORY_COUNTERS_EX).cast::<PROCESS_MEMORY_COUNTERS>(),
std::mem::size_of::<PROCESS_MEMORY_COUNTERS_EX>() as u32,
)
.map_err(os)?;
let (mut created, mut exited, mut kernel, mut user) = (
FILETIME::default(),
FILETIME::default(),
FILETIME::default(),
FILETIME::default(),
);
GetProcessTimes(process, &mut created, &mut exited, &mut kernel, &mut user)
.map_err(os)?;
let mut io_counters = IO_COUNTERS::default();
GetProcessIoCounters(process, &mut io_counters).map_err(os)?;
Ok(ProcessUsage {
resident_bytes: mem.WorkingSetSize as u64,
footprint_bytes: mem.PrivateUsage as u64,
cpu_seconds: (ticks(kernel) + ticks(user)) as f64 / 1e7,
wakeups_total: wakeups_total(),
disk_ops_total: Some(
io_counters.ReadOperationCount + io_counters.WriteOperationCount,
),
disk_read_bytes: Some(io_counters.ReadTransferCount),
disk_write_bytes: Some(io_counters.WriteTransferCount),
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(any(
target_os = "macos",
target_os = "linux",
target_os = "android",
windows
))]
#[test]
fn sample_reports_resident_memory_and_cpu() {
let first = process_usage().unwrap();
assert!(first.resident_bytes > 0);
assert!(first.footprint_bytes > 0);
let mut sink = 0u64;
for i in 0..20_000_000u64 {
sink = sink.wrapping_add(i * 31);
}
std::hint::black_box(sink);
let second = process_usage().unwrap();
assert!(second.cpu_seconds >= first.cpu_seconds);
let rates = second.rates_since(&first, Duration::from_secs(1));
assert!(rates.cpu_percent >= 0.0);
}
#[cfg(all(windows, target_pointer_width = "64"))]
#[test]
fn windows_wakeups_count_thread_context_switches() {
use std::sync::{mpsc, Arc, Mutex};
let (tx, rx) = mpsc::channel::<()>();
let rx = Arc::new(Mutex::new(rx));
let (done_tx, done_rx) = mpsc::channel::<()>();
let workers: Vec<_> = (0..3)
.map(|_| {
let rx = rx.clone();
let done = done_tx.clone();
std::thread::spawn(move || {
while rx.lock().map(|r| r.recv().is_ok()).unwrap_or(false) {
let _ = done.send(());
}
})
})
.collect();
let mut ok = false;
for _ in 0..5 {
let first = process_usage()
.unwrap()
.wakeups_total
.expect("windows reports wake-ups");
for _ in 0..5 {
std::thread::sleep(Duration::from_millis(10));
tx.send(()).unwrap();
done_rx.recv().unwrap();
}
let second = process_usage()
.unwrap()
.wakeups_total
.expect("windows reports wake-ups");
if second >= first {
ok = true;
break;
}
}
drop(tx);
for worker in workers {
let _ = worker.join();
}
assert!(ok, "wake-up total decreased on every attempt");
}
#[cfg(all(
unix,
not(target_os = "macos"),
not(any(target_os = "linux", target_os = "android"))
))]
#[test]
fn unsupported_unix_targets_report_unsupported_not_a_sample() {
let error = process_usage().expect_err("no sampler exists for this target");
assert_eq!(error.kind(), io::ErrorKind::Unsupported);
let error = observe_idle(Duration::from_millis(1)).expect_err("idle needs sampling");
assert_eq!(error.kind(), io::ErrorKind::Unsupported);
}
#[cfg(any(
target_os = "macos",
target_os = "linux",
target_os = "android",
windows
))]
#[test]
fn tree_usage_counts_the_remembered_root_and_refuses_a_replaced_one() {
let pid = std::num::NonZeroU32::new(std::process::id()).unwrap();
let root = crate::AdoptedIdentity::capture(pid)
.unwrap()
.expect("this process has an identity");
let usage = tree_usage(&root).unwrap();
assert!(usage.processes >= 1, "{usage:?}");
assert!(usage.resident_bytes > 0, "{usage:?}");
let replaced = crate::AdoptedIdentity::from_recorded(
pid,
crate::StartToken {
major: root.start_token().major.wrapping_add(1),
minor: root.start_token().minor,
},
None,
);
assert_eq!(tree_usage(&replaced).unwrap(), TreeUsage::default());
}
#[test]
fn rates_are_none_without_counters_and_zero_for_no_time() {
let a = ProcessUsage {
wakeups_total: None,
..Default::default()
};
let b = ProcessUsage {
wakeups_total: Some(10),
..Default::default()
};
assert_eq!(
b.rates_since(&a, Duration::from_secs(1)).wakeups_per_second,
None
);
let c = ProcessUsage {
wakeups_total: Some(30),
..Default::default()
};
assert_eq!(
c.rates_since(&b, Duration::from_secs(2)).wakeups_per_second,
Some(10.0)
);
assert_eq!(c.rates_since(&b, Duration::ZERO).cpu_percent, 0.0);
}
}