use crate::config::AppMatch;
use crate::sensor::{
AppCpuUtilization, AppSample, CpuTotals, CpuUtilization, ProcessCpuUtilization,
};
use std::collections::{HashMap, HashSet};
use std::time::{Duration, Instant};
#[cfg(not(target_os = "windows"))]
use sysinfo::{ProcessesToUpdate, System};
pub(crate) struct TotalsSampler {
read: fn() -> Option<CpuTotals>,
last: Option<CpuTotals>,
}
impl TotalsSampler {
pub(crate) fn new(read: fn() -> Option<CpuTotals>) -> Self {
Self { read, last: read() }
}
}
impl CpuUtilization for TotalsSampler {
fn cpu_utilization(&mut self) -> f64 {
let Some(current) = (self.read)() else {
log::debug!("CPU counters are unreadable; reporting 0% for this sample");
return 0.0;
};
match self.last.replace(current) {
Some(previous) => current.utilization_since(&previous),
None => 0.0,
}
}
fn last_totals(&self) -> Option<CpuTotals> {
self.last
}
}
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[derive(Debug, Default)]
pub(crate) struct CpuTimeDelta {
previous: Option<Snapshot>,
}
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[derive(Clone, Copy, Debug)]
struct Snapshot {
cpu_total: u64,
process_time: u64,
}
#[cfg(any(target_os = "linux", target_os = "windows"))]
impl CpuTimeDelta {
pub(crate) fn share(&mut self, cpu_total: u64, process_time: u64) -> f64 {
let current = Snapshot {
cpu_total,
process_time,
};
let Some(previous) = self.previous.replace(current) else {
return 0.0;
};
let cpu_delta = cpu_total.saturating_sub(previous.cpu_total);
let process_delta = process_time.saturating_sub(previous.process_time);
if cpu_delta == 0 {
0.0
} else {
process_delta as f64 / cpu_delta as f64
}
}
}
pub(crate) fn matches_app_name(process_name: &str, app_name: &str, app_match: AppMatch) -> bool {
fn strip_exe(name: &str) -> &str {
name.strip_suffix(".exe").unwrap_or(name)
}
let process_name = process_name.to_lowercase();
let app_name = app_name.to_lowercase();
match app_match {
AppMatch::Exact => strip_exe(&process_name) == strip_exe(&app_name),
AppMatch::Contains => process_name.contains(&app_name),
}
}
#[cfg(target_os = "windows")]
type Pids = crate::platform::windows::cpu::ToolhelpPids;
#[cfg(not(target_os = "windows"))]
type Pids = SysinfoPids;
#[cfg(not(target_os = "windows"))]
#[derive(Debug, Default)]
pub(crate) struct SysinfoPids {
system: System,
}
#[cfg(not(target_os = "windows"))]
impl SysinfoPids {
fn matching_pids(&mut self, app_name: &str, app_match: AppMatch) -> Vec<u32> {
self.system.refresh_processes(ProcessesToUpdate::All, true);
self.system
.processes()
.iter()
.filter(|(_, process)| process.thread_kind().is_none())
.filter(|(_, process)| {
matches_app_name(&process.name().to_string_lossy(), app_name, app_match)
})
.map(|(pid, _)| pid.as_u32())
.collect()
}
}
pub(crate) struct AppMonitor {
process_trackers: HashMap<u32, Box<dyn ProcessCpuUtilization>>,
tracker_factory: fn() -> Box<dyn ProcessCpuUtilization>,
pid_source: Pids,
last_pid_sweep: Option<Instant>,
cached_pids: Vec<u32>,
sweep_interval: Duration,
app_match: AppMatch,
}
impl AppMonitor {
pub(crate) fn boxed(
tracker_factory: fn() -> Box<dyn ProcessCpuUtilization>,
sweep_interval: Duration,
app_match: AppMatch,
) -> Box<dyn AppCpuUtilization> {
Box::new(Self {
process_trackers: HashMap::new(),
tracker_factory,
pid_source: Pids::default(),
last_pid_sweep: None,
cached_pids: Vec::new(),
sweep_interval,
app_match,
})
}
fn pids_for(&mut self, app_name: &str) -> Vec<u32> {
if !self.sweep_interval.is_zero()
&& let Some(last) = self.last_pid_sweep
&& last.elapsed() < self.sweep_interval
{
return self.cached_pids.clone();
}
let pids = self.pid_source.matching_pids(app_name, self.app_match);
self.cached_pids = pids.clone();
self.last_pid_sweep = Some(Instant::now());
pids
}
fn sync_trackers(&mut self, current_pids: &[u32]) {
let live: HashSet<u32> = current_pids.iter().copied().collect();
self.process_trackers.retain(|pid, _| live.contains(pid));
for &pid in current_pids {
self.process_trackers
.entry(pid)
.or_insert_with(self.tracker_factory);
}
}
}
impl AppCpuUtilization for AppMonitor {
fn app_snapshot(&mut self, app_name: &str, cpu_total: Option<u64>) -> AppSample {
let pids = self.pids_for(app_name);
if pids.is_empty() {
self.process_trackers.clear();
return AppSample::default();
}
self.sync_trackers(&pids);
let mut utilization = 0.0;
for &pid in &pids {
if let Some(tracker) = self.process_trackers.get_mut(&pid) {
utilization += tracker.process_cpu_utilization(pid, cpu_total);
}
}
AppSample { utilization, pids }
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
static READINGS: Mutex<Vec<Option<CpuTotals>>> = Mutex::new(Vec::new());
static NEXT: AtomicUsize = AtomicUsize::new(0);
fn scripted_read() -> Option<CpuTotals> {
let readings = READINGS.lock().unwrap_or_else(|e| e.into_inner());
let index = NEXT.fetch_add(1, Ordering::SeqCst);
readings.get(index).copied().flatten()
}
fn script(readings: Vec<Option<CpuTotals>>) {
*READINGS.lock().unwrap_or_else(|e| e.into_inner()) = readings;
NEXT.store(0, Ordering::SeqCst);
}
#[test]
fn totals_sampler_reports_usage_between_readings() {
script(vec![
Some(CpuTotals::new(1_000, 800)),
Some(CpuTotals::new(1_100, 850)),
None,
Some(CpuTotals::new(1_300, 900)),
]);
let mut sampler = TotalsSampler::new(scripted_read);
assert_eq!(sampler.last_totals(), Some(CpuTotals::new(1_000, 800)));
assert_eq!(sampler.cpu_utilization(), 0.5);
assert_eq!(sampler.last_totals(), Some(CpuTotals::new(1_100, 850)));
assert_eq!(sampler.cpu_utilization(), 0.0);
assert_eq!(sampler.last_totals(), Some(CpuTotals::new(1_100, 850)));
assert_eq!(sampler.cpu_utilization(), 0.75);
}
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[test]
fn a_process_share_is_one_counter_delta_over_the_other() {
let mut delta = CpuTimeDelta::default();
assert_eq!(delta.share(1_000, 0), 0.0);
assert_eq!(delta.share(1_100, 25), 0.25);
}
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[test]
fn a_system_counter_that_does_not_advance_reports_no_usage() {
let mut delta = CpuTimeDelta::default();
assert_eq!(delta.share(500, 0), 0.0);
assert_eq!(delta.share(500, 25), 0.0);
}
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[test]
fn counters_moving_backwards_do_not_underflow() {
let mut delta = CpuTimeDelta::default();
assert_eq!(delta.share(1_000, 900), 0.0);
assert_eq!(delta.share(1_100, 5), 0.0);
}
#[test]
fn exact_match_ignores_case_and_exe_suffix() {
assert!(matches_app_name("Firefox.exe", "firefox", AppMatch::Exact));
assert!(matches_app_name("firefox", "Firefox.exe", AppMatch::Exact));
assert!(!matches_app_name("firefox-bin", "firefox", AppMatch::Exact));
assert!(!matches_app_name("codesign", "code", AppMatch::Exact));
}
#[test]
fn contains_match_is_a_substring_test() {
assert!(matches_app_name(
"firefox-bin",
"firefox",
AppMatch::Contains
));
assert!(matches_app_name("codesign", "code", AppMatch::Contains));
assert!(!matches_app_name("bash", "firefox", AppMatch::Contains));
}
}