use std::sync::{Arc, Mutex};
use std::time::Duration;
use crate::config::{AppMatch, ElevationPolicy};
use crate::platform::cpu_usage::AppMonitor;
use crate::platform::macos_parse::PowerReading;
use crate::sensor::{
AppCpuUtilization, CpuUtilization, Platform, PowerSensor, ProcessCpuUtilization,
};
use crate::{Error, Result};
use powermetrics::PowerMetrics;
type SharedSampler = Arc<Mutex<Option<PowerMetrics>>>;
pub(crate) struct MacOsPlatform {
sampler: SharedSampler,
elevation: ElevationPolicy,
}
impl MacOsPlatform {
pub(crate) fn new(elevation: ElevationPolicy) -> Self {
Self {
sampler: Arc::new(Mutex::new(None)),
elevation,
}
}
fn ensure_sampler(&self) -> SharedSampler {
let mut sampler = self.sampler.lock().unwrap_or_else(|e| e.into_inner());
if sampler.is_none() {
match PowerMetrics::start(self.elevation) {
Ok(started) => *sampler = Some(started),
Err(e) => log::warn!(
"powermetrics is unavailable, so CPU and GPU power will be \
reported as unavailable: {e}"
),
}
}
drop(sampler);
self.sampler.clone()
}
}
impl Platform for MacOsPlatform {
fn cpu(&self) -> Box<dyn PowerSensor> {
Box::new(MacOsCpu {
sampler: self.ensure_sampler(),
})
}
fn gpu(&self) -> Box<dyn PowerSensor> {
Box::new(MacOsGpu {
sampler: self.ensure_sampler(),
})
}
fn cpu_usage(&self) -> Box<dyn CpuUtilization> {
Box::new(cpu::cpu_usage())
}
fn process_cpu_usage(&self) -> Option<Box<dyn ProcessCpuUtilization>> {
Some(cpu::process_tracker())
}
fn app_cpu_usage(
&self,
refresh_interval: Duration,
app_match: AppMatch,
) -> Option<Box<dyn AppCpuUtilization>> {
Some(AppMonitor::boxed(
cpu::process_tracker,
refresh_interval,
app_match,
))
}
}
struct MacOsCpu {
sampler: SharedSampler,
}
impl PowerSensor for MacOsCpu {
fn power(&mut self) -> Result<f64> {
Ok(current_reading(&self.sampler)?.cpu_power)
}
}
struct MacOsGpu {
sampler: SharedSampler,
}
impl PowerSensor for MacOsGpu {
fn power(&mut self) -> Result<f64> {
separate_gpu_power(current_reading(&self.sampler)?)
}
}
fn separate_gpu_power(reading: PowerReading) -> Result<f64> {
reading.gpu_power.ok_or_else(|| {
Error::sensor(
powermetrics::SENSOR,
"the latest sample has no separate GPU power reading",
)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_gpu_power_is_unavailable_not_zero() {
let error = separate_gpu_power(PowerReading {
cpu_power: 10.0,
gpu_power: None,
})
.unwrap_err();
assert!(matches!(error, Error::SensorUnavailable { .. }));
}
#[test]
fn separate_gpu_power_is_preserved() {
assert_eq!(
separate_gpu_power(PowerReading {
cpu_power: 10.0,
gpu_power: Some(2.5),
})
.unwrap(),
2.5
);
}
}
fn current_reading(sampler: &Mutex<Option<PowerMetrics>>) -> Result<PowerReading> {
let mut guard = sampler.lock().unwrap_or_else(|e| e.into_inner());
let sampler = guard
.as_mut()
.ok_or_else(|| Error::sensor(powermetrics::SENSOR, "sampler is not running"))?;
sampler.latest_reading()
}
mod powermetrics {
use std::io::{BufRead, BufReader};
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
use crate::config::ElevationPolicy;
use crate::platform::macos_parse::{PowerReading, parse_block};
use crate::{Error, Result};
pub(crate) const SENSOR: &str = "powermetrics";
const SUDO: &str = "/usr/bin/sudo";
const ENV: &str = "/usr/bin/env";
const POWERMETRICS: &str = "/usr/bin/powermetrics";
const SYSCTL: &str = "/usr/sbin/sysctl";
const SAMPLE_INTERVAL_MS: u64 = 1000;
const MAX_READING_AGE: Duration = Duration::from_millis(SAMPLE_INTERVAL_MS * 5);
#[derive(Debug)]
pub(crate) struct PowerMetrics {
last_reading: Arc<Mutex<Option<(PowerReading, Instant)>>>,
child: Child,
}
impl PowerMetrics {
pub(crate) fn start(elevation: ElevationPolicy) -> Result<Self> {
let is_apple_silicon = is_apple_silicon();
let is_root = is_root();
if !is_root && elevation == ElevationPolicy::Never {
return Err(Error::permission(
SENSOR,
"run as root, or cache a sudo credential and use \
ElevationPolicy::SudoNonInteractive",
));
}
let mut child = build_powermetrics_command(is_root)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| Error::sensor(SENSOR, format!("failed to spawn: {e}")))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| Error::sensor(SENSOR, "no stdout pipe"))?;
let stderr = child
.stderr
.take()
.ok_or_else(|| Error::sensor(SENSOR, "no stderr pipe"))?;
let last_reading = Arc::new(Mutex::new(None));
let readings = last_reading.clone();
thread::spawn(move || {
let mut block = String::new();
for line in BufReader::new(stdout).lines() {
let Ok(line) = line else {
log::debug!("powermetrics stdout closed");
break;
};
if line.starts_with("*** Sample") && !block.is_empty() {
if let Some(reading) = parse_block(&block, is_apple_silicon) {
*readings.lock().unwrap_or_else(|e| e.into_inner()) =
Some((reading, Instant::now()));
}
block.clear();
}
block.push_str(&line);
block.push('\n');
}
});
thread::spawn(move || {
for line in BufReader::new(stderr)
.lines()
.map_while(std::result::Result::ok)
{
log::warn!("powermetrics: {line}");
}
});
Ok(Self {
last_reading,
child,
})
}
pub(crate) fn latest_reading(&mut self) -> Result<PowerReading> {
if let Ok(Some(status)) = self.child.try_wait() {
return Err(Error::sensor(
SENSOR,
format!("sampler exited with {status}"),
));
}
let last = self.last_reading.lock().unwrap_or_else(|e| e.into_inner());
let Some((reading, taken_at)) = *last else {
return Err(Error::sensor(SENSOR, "no sample yet"));
};
let age = taken_at.elapsed();
if age > MAX_READING_AGE {
return Err(Error::sensor(
SENSOR,
format!("last sample is {age:.1?} old"),
));
}
Ok(reading)
}
}
impl Drop for PowerMetrics {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
fn build_powermetrics_command(is_root: bool) -> Command {
let powermetrics_args = [
"LC_ALL=C",
"LANG=C",
POWERMETRICS,
"--samplers",
"cpu_power,gpu_power",
"-i",
"1000",
];
let mut command = if is_root {
Command::new(ENV)
} else {
let mut sudo = Command::new(SUDO);
sudo.arg("-n").arg(ENV);
sudo
};
command.args(powermetrics_args);
command
}
fn is_root() -> bool {
unsafe { libc::getuid() == 0 }
}
fn is_apple_silicon() -> bool {
let Ok(output) = Command::new(SYSCTL)
.args(["-n", "machdep.cpu.brand_string"])
.output()
else {
return false;
};
String::from_utf8_lossy(&output.stdout).contains("Apple")
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsStr;
fn args(command: &Command) -> Vec<String> {
command
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect()
}
#[test]
fn every_program_is_addressed_by_absolute_path() {
for program in [SUDO, ENV, POWERMETRICS, SYSCTL] {
assert!(program.starts_with('/'), "{program} is not absolute");
}
let command = build_powermetrics_command(false);
assert_eq!(command.get_program(), OsStr::new(SUDO));
assert!(args(&command).iter().any(|arg| arg == ENV));
assert!(args(&command).iter().any(|arg| arg == POWERMETRICS));
}
#[test]
fn an_unprivileged_process_never_prompts() {
let command = build_powermetrics_command(false);
let args = args(&command);
assert_eq!(command.get_program(), OsStr::new(SUDO));
assert!(args.iter().any(|arg| arg == "-n"));
}
#[test]
fn root_runs_powermetrics_without_sudo() {
let command = build_powermetrics_command(true);
let args = args(&command);
assert_eq!(command.get_program(), OsStr::new(ENV));
assert_eq!(args.first().map(String::as_str), Some("LC_ALL=C"));
}
#[test]
fn locale_is_forced_so_decimal_points_parse() {
let args = args(&build_powermetrics_command(false));
assert!(args.iter().any(|arg| arg == "LC_ALL=C"));
assert!(args.iter().any(|arg| arg == "LANG=C"));
}
#[test]
fn an_unprivileged_process_refuses_to_elevate_under_the_default_policy() {
if is_root() {
return;
}
let error = PowerMetrics::start(ElevationPolicy::Never).unwrap_err();
assert!(matches!(error, Error::PermissionDenied { .. }), "{error}");
}
}
}
mod cpu {
use libc::{HOST_CPU_LOAD_INFO, host_cpu_load_info_data_t, host_statistics64, integer_t};
use mach2::mach_init::mach_host_self;
use std::mem::{MaybeUninit, size_of};
use std::time::Instant;
use crate::platform::cpu_usage::TotalsSampler;
use crate::sensor::{CpuTotals, ProcessCpuUtilization};
pub(crate) fn cpu_usage() -> TotalsSampler {
TotalsSampler::new(host_cpu_totals)
}
pub(crate) fn process_tracker() -> Box<dyn ProcessCpuUtilization> {
Box::new(MacOsProcessTracker::new())
}
const CPU_STATE_USER: usize = 0;
const CPU_STATE_SYSTEM: usize = 1;
const CPU_STATE_IDLE: usize = 2;
const CPU_STATE_NICE: usize = 3;
fn host_cpu_totals() -> Option<CpuTotals> {
let mut info = MaybeUninit::<host_cpu_load_info_data_t>::uninit();
let mut count = (size_of::<host_cpu_load_info_data_t>() / size_of::<integer_t>()) as u32;
let result = unsafe {
host_statistics64(
mach_host_self(),
HOST_CPU_LOAD_INFO,
info.as_mut_ptr().cast(),
&mut count,
)
};
if result != 0 {
return None;
}
let ticks = unsafe { info.assume_init() }.cpu_ticks;
let user = ticks[CPU_STATE_USER] as u64;
let system = ticks[CPU_STATE_SYSTEM] as u64;
let idle = ticks[CPU_STATE_IDLE] as u64;
let nice = ticks[CPU_STATE_NICE] as u64;
Some(CpuTotals::new(user + system + idle + nice, idle))
}
const PROC_PIDTASKINFO: i32 = 4;
#[repr(C)]
#[derive(Default)]
#[allow(non_camel_case_types)]
struct proc_taskinfo {
pti_virtual_size: u64,
pti_resident_size: u64,
pti_total_user: u64,
pti_total_system: u64,
pti_threads_user: u64,
pti_threads_system: u64,
pti_policy: i32,
pti_faults: i32,
pti_pageins: i32,
pti_cow_faults: i32,
pti_messages_sent: i32,
pti_messages_received: i32,
pti_syscalls_mach: i32,
pti_syscalls_unix: i32,
pti_csw: i32,
pti_threadnum: i32,
pti_numrunning: i32,
pti_priority: i32,
}
#[repr(C)]
#[derive(Default)]
struct MachTimebaseInfo {
numer: u32,
denom: u32,
}
unsafe extern "C" {
fn proc_pidinfo(
pid: i32,
flavor: i32,
arg: u64,
buffer: *mut libc::c_void,
buffersize: i32,
) -> i32;
fn mach_timebase_info(info: *mut MachTimebaseInfo) -> libc::c_int;
}
struct MacOsProcessTracker {
previous: Option<(u64, Instant)>,
timebase_numer: u32,
timebase_denom: u32,
num_cores: u32,
}
impl MacOsProcessTracker {
fn new() -> Self {
let mut timebase = MachTimebaseInfo::default();
unsafe { mach_timebase_info(&mut timebase) };
let (numer, denom) = if timebase.denom > 0 {
(timebase.numer, timebase.denom)
} else {
(1, 1)
};
Self {
previous: None,
timebase_numer: numer,
timebase_denom: denom,
num_cores: logical_cpu_count(),
}
}
fn process_time(pid: u32) -> Option<u64> {
let mut info = proc_taskinfo::default();
let size = size_of::<proc_taskinfo>() as i32;
let written = unsafe {
proc_pidinfo(
pid as i32,
PROC_PIDTASKINFO,
0,
std::ptr::from_mut(&mut info).cast(),
size,
)
};
(written == size).then(|| info.pti_total_user + info.pti_total_system)
}
}
impl ProcessCpuUtilization for MacOsProcessTracker {
fn process_cpu_utilization(&mut self, pid: u32, _cpu_total: Option<u64>) -> f64 {
let Some(process_time) = Self::process_time(pid) else {
return 0.0;
};
let now = Instant::now();
let Some((previous_time, previous_at)) = self.previous.replace((process_time, now))
else {
return 0.0;
};
let elapsed_ns = now.duration_since(previous_at).as_nanos();
if elapsed_ns == 0 {
return 0.0;
}
let used_ns = (process_time.saturating_sub(previous_time) as u128
* self.timebase_numer as u128)
/ self.timebase_denom as u128;
used_ns as f64 / (elapsed_ns as f64 * self.num_cores as f64)
}
}
fn logical_cpu_count() -> u32 {
let mut cores: i32 = 0;
let mut size = size_of::<i32>();
let name = c"hw.logicalcpu";
let result = unsafe {
libc::sysctlbyname(
name.as_ptr(),
std::ptr::from_mut(&mut cores).cast(),
&mut size,
std::ptr::null_mut(),
0,
)
};
if result == 0 && cores > 0 {
cores as u32
} else {
1
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_running_host_reports_cpu_time() {
let totals = host_cpu_totals().expect("host_statistics64");
assert!(totals.total > 0);
assert!(totals.idle <= totals.total);
}
#[test]
fn this_machine_reports_at_least_one_core() {
assert!(logical_cpu_count() >= 1);
}
#[test]
fn a_process_that_does_not_exist_reports_nothing() {
assert_eq!(MacOsProcessTracker::process_time(0), None);
let mut tracker = MacOsProcessTracker::new();
assert_eq!(tracker.process_cpu_utilization(0, None), 0.0);
}
#[test]
fn the_first_sample_of_a_live_process_establishes_a_baseline() {
let mut tracker = MacOsProcessTracker::new();
let me = std::process::id();
assert_eq!(tracker.process_cpu_utilization(me, None), 0.0);
}
}
}