USB: P3 scheme service wrapper — redbear-ftdi registers /scheme/ttys/usbFTDI_<N>

Same pattern as acmd: FtdiScheme with Mutex<XhciEndpHandle>,
SchemeSync impl, Socket::create() + register on Redox,
stdout fallback on host/Linux.
This commit is contained in:
2026-07-07 19:24:46 +03:00
parent 726e628e0d
commit 9e6851d43c
18 changed files with 1025 additions and 36 deletions
Generated
+4 -4
View File
@@ -592,7 +592,7 @@ dependencies = [
[[package]]
name = "libredox"
version = "0.1.18+rb0.2.5"
version = "0.1.18+rb0.3.0"
dependencies = [
"bitflags",
"libc",
@@ -910,12 +910,12 @@ dependencies = [
[[package]]
name = "redox_installer"
version = "0.2.42+rb0.2.5"
version = "0.2.42+rb0.3.0"
dependencies = [
"anyhow",
"arg_parser",
"libc",
"libredox 0.1.18+rb0.2.5",
"libredox 0.1.18+rb0.3.0",
"ring",
"serde",
"serde_derive",
@@ -924,7 +924,7 @@ dependencies = [
[[package]]
name = "redox_syscall"
version = "0.9.0+rb0.2.5"
version = "0.9.0+rb0.3.0"
dependencies = [
"bitflags",
]
@@ -14,6 +14,9 @@ xhcid = { path = "../../../../../local/sources/base/drivers/usb/xhcid" }
common = { path = "../../../../../local/sources/base/drivers/common" }
libredox = { path = "../../../../../local/sources/libredox", features = ["call", "std"] }
[target.'cfg(target_os = "redox")'.dependencies]
redox-scheme = { path = "../../../../../local/sources/redox-scheme" }
[patch.crates-io]
redox_syscall = { path = "../../../../../local/sources/syscall" }
libredox = { path = "../../../../../local/sources/libredox" }
@@ -3,6 +3,11 @@
//! Cross-referenced with Linux 7.1 `drivers/usb/serial/ftdi_sio.c`
//! (2,876 lines). Implements the core FTDI protocol: reset, baud
//! rate setting, flow control, and bidirectional bulk read/write.
//!
//! On Redox: registers /scheme/ttys/usbFTDI_<N> for serial console use.
#[cfg(target_os = "redox")]
mod scheme;
use std::{env, io, io::{Read, Write}, thread, time};
@@ -142,6 +147,28 @@ fn main() {
log::info!("FTDI ready on {} port {} — bulk_in={} bulk_out={}",
scheme, port_id, bulk_in_num, bulk_out_num);
// ── Scheme IPC path (Redox) ──
#[cfg(target_os = "redox")]
{
use redox_scheme::scheme::SchemeSync;
use redox_scheme::{Socket, SignalBehavior};
let scheme_name = format!("ttys/usbFTDI_{}", port_id);
log::info!("FTDI: registering /scheme/{}", scheme_name);
let socket = Socket::create().expect("FTDI: scheme socket");
let mut scheme_state = redox_scheme::scheme::SchemeState::new();
let mut ftdi_scheme = crate::scheme::FtdiScheme::new(dev.bulk_in, dev.bulk_out);
socket.register(&scheme_name).expect("FTDI: scheme register");
loop {
let req = socket.next_request(SignalBehavior::Restart).expect("FTDI: scheme request");
if let Err(e) = req.handle_scheme_mut(&mut ftdi_scheme, &mut scheme_state) {
log::warn!("FTDI: scheme error: {}", e);
}
}
}
// ── Stdout path (host/Linux testing) ──
#[cfg(not(target_os = "redox"))]
{
let mut bulk_out = dev.bulk_out;
let _writer = thread::spawn(move || {
let mut buf = [0u8; 1024];
@@ -173,4 +200,5 @@ fn main() {
}
thread::sleep(time::Duration::from_millis(10));
}
}
}
@@ -0,0 +1,49 @@
use std::sync::Mutex;
use redox_scheme::scheme::SchemeSync;
use redox_scheme::{CallerCtx, OpenResult};
use syscall::error::{Error, Result, EBADF, EINVAL};
use syscall::flag::{MODE_FILE, O_RDWR, O_STAT};
use xhcid_interface::XhciEndpHandle;
pub struct FtdiScheme {
bulk_in: Mutex<XhciEndpHandle>,
bulk_out: Mutex<XhciEndpHandle>,
open_count: Mutex<usize>,
}
impl FtdiScheme {
pub fn new(bulk_in: XhciEndpHandle, bulk_out: XhciEndpHandle) -> Self {
Self { bulk_in: Mutex::new(bulk_in), bulk_out: Mutex::new(bulk_out), open_count: Mutex::new(0) }
}
}
impl SchemeSync for FtdiScheme {
fn openat(&self, id: usize, path: &str, _flags: usize, _ctx: &CallerCtx) -> Result<OpenResult> {
if id != 1 { return Err(Error::new(EBADF)); }
if path.is_empty() || path == "." || path == "/" {
*self.open_count.lock().unwrap_or_else(|e| e.into_inner()) += 1;
return Ok(OpenResult::ThisScheme { number: 1, flags: O_STAT | MODE_FILE });
}
let mut count = self.open_count.lock().unwrap_or_else(|e| e.into_inner());
let next_id = 2 + *count;
*count += 1;
Ok(OpenResult::OtherScheme { number: next_id, flags: O_RDWR })
}
fn read(&mut self, id: usize, buf: &mut [u8], _ctx: &CallerCtx) -> Result<usize> {
if id < 2 || buf.is_empty() { return Err(Error::new(EBADF)); }
let mut b = self.bulk_in.lock().unwrap_or_else(|e| e.into_inner());
b.transfer_read(buf).map(|s| s.bytes_transferred as usize).map_err(|e| { log::warn!("FTDI scheme: read: {}", e); Error::new(EINVAL) })
}
fn write(&mut self, id: usize, buf: &[u8], _ctx: &CallerCtx) -> Result<usize> {
if id < 2 || buf.is_empty() { return Err(Error::new(EBADF)); }
let mut b = self.bulk_out.lock().unwrap_or_else(|e| e.into_inner());
b.transfer_write(buf).map(|s| s.bytes_transferred as usize).map_err(|e| { log::warn!("FTDI scheme: write: {}", e); Error::new(EINVAL) })
}
fn fsync(&mut self, _id: usize, _ctx: &CallerCtx) -> Result<()> { Ok(()) }
fn close(&self, id: usize, _ctx: &CallerCtx) -> Result<()> {
if id >= 2 { *self.open_count.lock().unwrap_or_else(|e| e.into_inner()) = self.open_count.lock().unwrap_or_else(|e| e.into_inner()).saturating_sub(1); }
Ok(())
}
}
@@ -22,5 +22,9 @@ lto = true
opt-level = 3
codegen-units = 1
[target.'cfg(target_os = "redox")'.dependencies]
libredox = { path = "../../../../../local/sources/libredox", features = ["call", "redox_syscall", "protocol"] }
syscall = { package = "redox_syscall", path = "../../../../../local/sources/syscall" }
[target.'cfg(target_os = "linux")'.dependencies]
libc = "0.2"
libc = "0.2"
@@ -21,12 +21,14 @@ use crate::acpi::{
};
use crate::cpufreq::Cpufreq;
use crate::cpuid::{self, CoreType, CpuId};
use crate::cpuidle::CpuidleStats;
use crate::msr::{
IA32_PERF_CTL, PERF_CTL_STATE_MASK, PackageThermal, THERM_STATUS_CRITICAL,
THERM_STATUS_POWER_LIMIT, THERM_STATUS_PROCHOT, THERM_STATUS_READOUT_VALID,
THERM_STATUS_TEMP_MASK, read_current_perf_ctl, read_package_thermal_status,
read_thermal_status, write_msr,
};
use crate::wakeup::WakeupStats;
fn read_load_avg() -> Option<(f64, f64, f64)> {
let s = std::fs::read_to_string("/proc/loadavg").ok()?;
@@ -156,6 +158,19 @@ pub struct App {
/// `/proc/stat` (Linux). Read every tick — it's a single
/// file read. Fields degrade to `None` when unavailable.
pub sched_stats: crate::sched::SchedStats,
/// Previous per-CPU scheduler time breakdown, used to render
/// htop-style CPU state bars from cumulative counters.
pub prev_sched_breakdown: Vec<crate::sched::CpuTimeBreakdown>,
/// CPU idle (C-state) residency percentages per CPU, read from
/// `/sys/devices/system/cpu/cpu*/cpuidle/state*/time`.
pub cpuidle_stats: CpuidleStats,
/// Total system interrupts from the previous refresh, used to
/// compute wakeups per second.
pub wakeup_prev_total: Option<u64>,
/// System wakeups per second (and cumulative total interrupts).
pub wakeup_stats: WakeupStats,
/// Hide kernel threads in the Process tab.
pub hide_kernel_threads: bool,
pub load_avg: Option<(f64, f64, f64)>,
pub poll_ms: u64,
pub theme_idx: usize,
@@ -464,6 +479,11 @@ impl App {
process_sort: crate::process::SortMode::default(),
process_filter: String::new(),
sched_stats: crate::sched::SchedStats::default(),
prev_sched_breakdown: Vec::new(),
cpuidle_stats: CpuidleStats::default(),
wakeup_prev_total: None,
wakeup_stats: WakeupStats::default(),
hide_kernel_threads: false,
load_avg: read_load_avg(),
poll_ms: 0,
theme_idx: 0,
@@ -532,6 +552,7 @@ impl App {
app.show_full_cmdline = session.show_full_cmdline;
app.process_narrow = session.process_narrow;
app.poll_ms = session.refresh_ms;
app.hide_kernel_threads = session.hide_kernel_threads;
app
}
@@ -555,6 +576,7 @@ impl App {
show_full_cmdline: self.show_full_cmdline,
process_narrow: self.process_narrow,
refresh_ms: self.poll_ms,
hide_kernel_threads: self.hide_kernel_threads,
};
session.save();
}
@@ -737,7 +759,23 @@ impl App {
// Scheduler stats (context switches, IRQs). One file read
// — cheap enough for every tick.
self.sched_stats = crate::sched::SchedStats::read();
let next_sched_stats = crate::sched::SchedStats::read();
if !next_sched_stats.per_cpu_time_breakdown.is_empty() {
self.prev_sched_breakdown =
std::mem::take(&mut self.sched_stats.per_cpu_time_breakdown);
}
self.sched_stats = next_sched_stats;
// CPU C-state residency from sysfs. Cheap on Linux; degrades
// to empty stats when the sysfs tree is unavailable.
self.cpuidle_stats = CpuidleStats::read();
// System-wide wakeups per second from /proc/stat interrupts.
let dt = Duration::from_millis(self.poll_ms.max(1));
if let Some(stats) = crate::wakeup::read_system_wakeups(self.wakeup_prev_total, dt) {
self.wakeup_prev_total = Some(stats.system_total);
self.wakeup_stats = stats;
}
let pkg_temps: Vec<Option<u32>> = self
.cpus
@@ -1195,16 +1233,34 @@ impl App {
/// Process tab renders this many rows and `process_cursor` is
/// bounded to this count.
pub(crate) fn visible_processes(&self) -> Vec<&crate::process::ProcessInfo> {
self.processes
.processes
.iter()
.filter(|p| {
self.process_filter.is_empty()
|| p.comm
.to_lowercase()
.contains(&self.process_filter.to_lowercase())
})
.collect()
self.visible_processes_with(self.hide_kernel_threads)
}
pub(crate) fn visible_processes_with(
&self,
hide_kernel_threads: bool,
) -> Vec<&crate::process::ProcessInfo> {
crate::process::visible_processes(
&self.processes.processes,
&self.process_filter,
hide_kernel_threads,
)
}
pub fn toggle_kernel_threads(&mut self) {
self.hide_kernel_threads = !self.hide_kernel_threads;
}
pub fn swap_used_pct(&self) -> Option<f64> {
self.meminfo.swap_used_pct()
}
pub fn wakeups_per_second(&self) -> f64 {
self.wakeup_stats.system_per_second
}
pub fn cpuidle_stats(&self) -> &CpuidleStats {
&self.cpuidle_stats
}
/// Update all per-PID history maps (IO rate, CPU%, RSS) from
@@ -1878,6 +1934,7 @@ mod tests {
show_full_cmdline: app.show_full_cmdline,
process_narrow: app.process_narrow,
refresh_ms: app.poll_ms,
hide_kernel_threads: app.hide_kernel_threads,
};
let serialized = toml::to_string(&session).unwrap();
let parsed: crate::session::SessionState = toml::from_str(&serialized).unwrap();
@@ -28,17 +28,22 @@ unsafe extern "C" {
}
fn try_pin_cpu(cpu_id: u32) -> bool {
if cpu_id >= 64 {
if cpu_id >= 128 {
return false;
}
let mask: u64 = 1u64 << cpu_id;
let bytes = mask.to_le_bytes();
let mut mask = [0u64; 2];
let idx = (cpu_id as usize) / 64;
let bit = (cpu_id as usize) % 64;
mask[idx] |= 1u64 << bit;
let mut bytes = [0u8; 16];
bytes[..8].copy_from_slice(&mask[0].to_le_bytes());
bytes[8..].copy_from_slice(&mask[1].to_le_bytes());
if std::fs::write("/proc/self/sched-affinity", bytes).is_ok() {
return true;
}
#[cfg(target_os = "linux")]
unsafe {
sched_setaffinity(0, std::mem::size_of::<u64>(), &mask) == 0
sched_setaffinity(0, std::mem::size_of::<u64>(), &mask as *const u64) == 0
}
#[cfg(not(target_os = "linux"))]
{
@@ -0,0 +1,153 @@
//! CPU idle residency stats from sysfs.
//!
//! Reads per-CPU C-state names and accumulated residency time from:
//! `/sys/devices/system/cpu/cpu*/cpuidle/state*/{name,time}`.
//! Missing paths degrade to empty stats.
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
#[derive(Clone, Debug, Default, PartialEq)]
pub struct CStateResidency {
pub name: String,
pub time: u64,
pub percentage: f64,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct CpuidleStats {
pub per_cpu: BTreeMap<u32, Vec<CStateResidency>>,
}
impl CpuidleStats {
pub fn read() -> Self {
Self::read_from(Path::new("/sys/devices/system/cpu"))
}
pub(crate) fn read_from(base: &Path) -> Self {
let mut stats = Self::default();
let Ok(entries) = fs::read_dir(base) else {
return stats;
};
for entry in entries.flatten() {
let path = entry.path();
let Some(cpu_id) = parse_cpu_dir(&path) else {
continue;
};
let cpuidle_dir = path.join("cpuidle");
let mut states = read_cpu_states(&cpuidle_dir);
normalize_percentages(&mut states);
if !states.is_empty() {
stats.per_cpu.insert(cpu_id, states);
}
}
stats
}
}
fn parse_cpu_dir(path: &Path) -> Option<u32> {
let name = path.file_name()?.to_str()?;
name.strip_prefix("cpu")?.parse().ok()
}
fn read_cpu_states(cpuidle_dir: &Path) -> Vec<CStateResidency> {
let Ok(entries) = fs::read_dir(cpuidle_dir) else {
return Vec::new();
};
let mut states = Vec::new();
for entry in entries.flatten() {
let state_dir = entry.path();
if !state_dir.is_dir() {
continue;
}
let name = read_trimmed(&state_dir.join("name"));
let time = read_trimmed(&state_dir.join("time"));
let (Some(name), Some(time)) = (name, time) else {
continue;
};
let Some(time) = time.parse().ok() else {
continue;
};
states.push(CStateResidency {
name,
time,
percentage: 0.0,
});
}
states.sort_by(|a, b| a.name.cmp(&b.name));
states
}
fn read_trimmed(path: &Path) -> Option<String> {
fs::read_to_string(path).ok().map(|s| s.trim().to_string())
}
fn normalize_percentages(states: &mut [CStateResidency]) {
let total: u64 = states.iter().map(|s| s.time).sum();
if total == 0 {
return;
}
for state in states {
state.percentage = state.time as f64 / total as f64;
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir() -> std::path::PathBuf {
let mut dir = std::env::temp_dir();
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time went backwards")
.as_nanos();
dir.push(format!("redbear-power-cpuidle-{unique}"));
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn normalize_percentages_sums_to_one() {
let mut states = vec![
CStateResidency {
name: "C1".into(),
time: 25,
percentage: 0.0,
},
CStateResidency {
name: "C2".into(),
time: 75,
percentage: 0.0,
},
];
normalize_percentages(&mut states);
assert!((states[0].percentage - 0.25).abs() < f64::EPSILON);
assert!((states[1].percentage - 0.75).abs() < f64::EPSILON);
assert!((states.iter().map(|s| s.percentage).sum::<f64>() - 1.0).abs() < f64::EPSILON);
}
#[test]
fn normalize_percentages_handles_zero_total() {
let mut states = vec![CStateResidency {
name: "C1".into(),
time: 0,
percentage: 0.0,
}];
normalize_percentages(&mut states);
assert_eq!(states[0].percentage, 0.0);
}
#[test]
fn read_from_unknown_layout_returns_empty() {
let dir = temp_dir();
let stats = CpuidleStats::read_from(&dir);
assert!(stats.per_cpu.is_empty());
let _ = fs::remove_dir_all(dir);
}
}
@@ -95,10 +95,12 @@ fn do_kill(pid: u32, sig: i32) -> Result<(), String> {
Err(std::io::Error::last_os_error().to_string())
}
}
#[cfg(not(target_os = "linux"))]
#[cfg(target_os = "redox")]
{
let _ = (pid, sig);
Err("process killing not supported on this platform".into())
let sig = sig as u32;
libredox::call::kill(pid as usize, sig)
.map(|_| ())
.map_err(|e| e.to_string())
}
}
@@ -54,6 +54,7 @@ mod collector;
mod config;
mod cpufreq;
mod cpuid;
mod cpuidle;
mod dbus;
mod dmi;
mod graph;
@@ -73,6 +74,7 @@ mod session;
mod smart;
mod storage;
mod theme;
mod wakeup;
use crate::app::{App, POLL_MS, TabId};
use crate::graph::BrailleGraph;
@@ -1237,6 +1239,17 @@ fn main() -> io::Result<()> {
}
));
}
Key::Char('K') if app.current_tab == TabId::Process => {
app.toggle_kernel_threads();
app.flash_status(format!(
"kernel threads: {}",
if app.hide_kernel_threads {
"hidden"
} else {
"shown"
}
));
}
Key::Char('C') if app.current_tab == TabId::Process => {
app.show_full_cmdline = !app.show_full_cmdline;
app.flash_status(format!(
@@ -25,6 +25,10 @@ pub struct MemInfo {
pub free_kib: u64,
/// Total swap in KiB (0 if no swap).
pub swap_total_kib: u64,
/// Free swap in KiB.
pub swap_free_kib: u64,
/// Swap cached in KiB.
pub swap_cached_kib: u64,
/// Used swap in KiB.
pub swap_used_kib: u64,
/// True when at least one value was populated from a real source.
@@ -62,6 +66,18 @@ impl MemInfo {
}
(self.swap_used_kib as f64 / self.swap_total_kib as f64) * 100.0
}
pub fn swap_used(&self) -> u64 {
self.swap_total_kib
.saturating_sub(self.swap_free_kib)
.saturating_sub(self.swap_cached_kib)
.max(self.swap_used_kib)
}
pub fn swap_used_pct(&self) -> Option<f64> {
if self.swap_total_kib == 0 {
return None;
}
Some((self.swap_used() as f64 / self.swap_total_kib as f64) * 100.0)
}
}
/// Read memory info. Tries Redox scheme first, then Linux `/proc/meminfo`.
@@ -126,13 +142,23 @@ fn parse_meminfo_kv(s: &str, info: &mut MemInfo) -> bool {
any = true;
}
"SwapFree" => {
// derive SwapUsed = SwapTotal - SwapFree
info.swap_used_kib = info.swap_total_kib.saturating_sub(v_kib);
info.swap_free_kib = v_kib;
any = true;
}
"SwapCached" => {
info.swap_cached_kib = v_kib;
any = true;
}
_ => {}
}
}
if info.swap_total_kib > 0 {
let used = info
.swap_total_kib
.saturating_sub(info.swap_free_kib)
.saturating_sub(info.swap_cached_kib);
info.swap_used_kib = used;
}
// If MemTotal was parsed but MemFree wasn't, fall back to "used = total - free - buffers - cached"
if info.used_kib == 0 && info.total_kib > 0 {
info.used_kib = info
@@ -256,3 +282,37 @@ pub fn format_uptime(secs: u64) -> String {
format!("{s}s")
}
}
#[cfg(test)]
mod tests {
use super::{MemInfo, parse_meminfo_kv};
#[test]
fn parses_swap_fields_and_computes_usage() {
let mut info = MemInfo::default();
let meminfo = "\
MemTotal: 8192 kB
MemFree: 4096 kB
Buffers: 512 kB
Cached: 1024 kB
SwapTotal: 2048 kB
SwapFree: 1536 kB
SwapCached: 256 kB
";
assert!(parse_meminfo_kv(meminfo, &mut info));
assert_eq!(info.swap_total_kib, 2048);
assert_eq!(info.swap_free_kib, 1536);
assert_eq!(info.swap_cached_kib, 256);
assert_eq!(info.swap_used(), 256);
assert_eq!(info.swap_used_kib, 256);
assert_eq!(info.swap_used_pct(), Some(12.5));
}
#[test]
fn handles_missing_swap_fields() {
let info = MemInfo::default();
assert_eq!(info.swap_used(), 0);
assert_eq!(info.swap_used_pct(), None);
}
}
@@ -8,9 +8,7 @@
//! this module we use `/proc/net/if_inet6` for IPv6 (one line per
//! address, easy to parse) and read IPv4 from a simpler source.
//!
//! On Redox, no equivalent scheme exists yet, so `read()` returns an
//! empty `NetInfo` and the render layer shows
//! `(no network interfaces detected)`.
//! On Redox, network configuration is exposed through `/scheme/netcfg/`.
use std::fs;
use std::path::Path;
@@ -128,6 +126,7 @@ fn read_ipv6_addrs(iface_name: &str) -> Vec<String> {
addrs
}
#[cfg(target_os = "linux")]
/// Read IPv4 addresses for a specific interface using libc getifaddrs.
fn read_ipv4_addrs(iface_name: &str) -> Vec<String> {
let mut addrs = Vec::new();
@@ -156,6 +155,41 @@ fn read_ipv4_addrs(iface_name: &str) -> Vec<String> {
addrs
}
#[cfg(target_os = "redox")]
fn read_trimmed(path: &Path) -> Option<String> {
fs::read_to_string(path)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
#[cfg(target_os = "redox")]
fn read_u64(path: &Path) -> Option<u64> {
read_trimmed(path)?.parse::<u64>().ok()
}
#[cfg(target_os = "redox")]
fn read_ipv4_addrs(iface_name: &str) -> Vec<String> {
let path = Path::new("/scheme/netcfg/ifaces")
.join(iface_name)
.join("addr/list");
let Ok(content) = fs::read_to_string(path) else {
return Vec::new();
};
content
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(|line| {
line.split_once('/')
.map(|(addr, _)| addr)
.unwrap_or(line)
.to_string()
})
.collect()
}
fn read_interface(name: &str, path: &Path) -> Option<NetInterface> {
Some(NetInterface {
name: name.to_string(),
@@ -179,9 +213,22 @@ fn read_interface(name: &str, path: &Path) -> Option<NetInterface> {
}
impl NetInfo {
#[cfg(target_os = "linux")]
pub fn available() -> bool {
Path::new(SYS_NET).is_dir()
}
#[cfg(target_os = "redox")]
pub fn available() -> bool {
Path::new("/scheme/netcfg/ifaces").is_dir()
}
#[cfg(not(any(target_os = "linux", target_os = "redox")))]
pub fn available() -> bool {
false
}
#[cfg(target_os = "linux")]
pub fn read() -> Self {
let Ok(dirs) = fs::read_dir(SYS_NET) else {
return Self::default();
@@ -199,6 +246,54 @@ impl NetInfo {
interfaces.sort_by(|a, b| a.name.cmp(&b.name));
Self { interfaces }
}
#[cfg(target_os = "redox")]
pub fn read() -> Self {
let root = Path::new("/scheme/netcfg/ifaces");
let Ok(dirs) = fs::read_dir(root) else {
return Self::default();
};
let mut interfaces = Vec::new();
for entry in dirs.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
let mac_address = read_trimmed(&path.join("mac"));
let ipv4_addrs = read_ipv4_addrs(name);
let operstate = read_trimmed(&path.join("state")).or_else(|| Some("up".to_string()));
interfaces.push(NetInterface {
name: name.to_string(),
operstate,
speed_mbps: None,
mac_address,
mtu: None,
rx_bytes: read_u64(&path.join("traffic/rx")).unwrap_or(0),
tx_bytes: read_u64(&path.join("traffic/tx")).unwrap_or(0),
rx_packets: 0,
tx_packets: 0,
rx_errors: 0,
tx_errors: 0,
rx_dropped: 0,
tx_dropped: 0,
ipv6_addrs: Vec::new(),
ipv4_addrs,
rx_kbps: 0.0,
tx_kbps: 0.0,
});
}
interfaces.sort_by(|a, b| a.name.cmp(&b.name));
Self { interfaces }
}
#[cfg(not(any(target_os = "linux", target_os = "redox")))]
pub fn read() -> Self {
Self::default()
}
/// Read interfaces and compute R/W throughput (KiB/s) for each
/// based on delta of rx_bytes/tx_bytes vs previous read.
pub fn read_with_throughput(prev: &NetInfo, dt_secs: f64) -> Self {
@@ -17,6 +17,8 @@
use std::fs;
#[cfg(target_os = "redox")]
use libredox::protocol::ProcCall;
use serde::{Deserialize, Serialize};
const MAX_PROCESSES: usize = 50;
@@ -486,6 +488,26 @@ pub struct ProcessInfo {
pub sched_policy: String,
}
/// Return true when a process is a kernel thread.
pub fn is_kernel_thread(proc: &ProcessInfo) -> bool {
proc.ppid == 2 || proc.comm.starts_with('[')
}
pub(crate) fn visible_processes<'a>(
processes: &'a [ProcessInfo],
process_filter: &str,
hide_kernel_threads: bool,
) -> Vec<&'a ProcessInfo> {
let filter_lower = process_filter.to_lowercase();
processes
.iter()
.filter(|p| {
(!hide_kernel_threads || !is_kernel_thread(p))
&& (filter_lower.is_empty() || p.comm.to_lowercase().contains(&filter_lower))
})
.collect()
}
impl ProcessInfo {
pub fn total_cpu_ticks(&self) -> u64 {
self.utime.saturating_add(self.stime)
@@ -1003,6 +1025,30 @@ impl ProcInfo {
mod tests {
use super::*;
fn mock_process(pid: u32, ppid: u32, comm: &str) -> ProcessInfo {
ProcessInfo {
pid,
ppid,
comm: comm.to_string(),
..Default::default()
}
}
#[test]
fn is_kernel_thread_matches_ppid_2() {
assert!(is_kernel_thread(&mock_process(42, 2, "kworker/0:1")));
}
#[test]
fn is_kernel_thread_matches_bracketed_comm() {
assert!(is_kernel_thread(&mock_process(43, 1, "[kthreadd]")));
}
#[test]
fn is_kernel_thread_ignores_regular_processes() {
assert!(!is_kernel_thread(&mock_process(44, 1, "bash")));
}
#[test]
fn format_memory_below_1kib() {
assert_eq!(ProcessInfo::format_memory_kb(500), "500.0 KiB");
@@ -2067,10 +2113,24 @@ pub fn set_nice(pid: u32, nice: i32) -> Result<(), String> {
Err(std::io::Error::last_os_error().to_string())
}
}
#[cfg(not(target_os = "linux"))]
#[cfg(target_os = "redox")]
{
let _ = (pid, n);
Err("setpriority not supported on this platform".into())
let kernel_prio = 20 + n;
let fd = libredox::call::open("/scheme/proc", 0, 0).map_err(|e| e.to_string())?;
let result = libredox::call::call_wo(
fd,
&[],
syscall::CallFlags::empty(),
&[
ProcCall::SetProcPriority as u64,
pid as u64,
kernel_prio as u64,
],
)
.map(|_| ())
.map_err(|e| e.to_string());
let close_result = libredox::call::close(fd).map_err(|e| e.to_string());
result.and(close_result)
}
}
@@ -2094,9 +2154,19 @@ pub fn set_affinity(pid: u32, cpus: &[u32]) -> Result<(), String> {
Err(std::io::Error::last_os_error().to_string())
}
}
#[cfg(not(target_os = "linux"))]
#[cfg(target_os = "redox")]
{
let _ = (pid, cpus);
Err("sched_setaffinity not supported on this platform".into())
let mut mask = [0u64; 2];
for &cpu in cpus {
if (cpu as usize) < 128 {
let idx = (cpu as usize) / 64;
let bit = (cpu as usize) % 64;
mask[idx] |= 1u64 << bit;
}
}
let mut bytes = [0u8; 16];
bytes[..8].copy_from_slice(&mask[0].to_le_bytes());
bytes[8..].copy_from_slice(&mask[1].to_le_bytes());
std::fs::write(format!("/proc/{pid}/sched-affinity"), bytes).map_err(|e| e.to_string())
}
}
@@ -188,6 +188,16 @@ pub fn render_header<'a>(app: &'a App, focused: bool) -> Paragraph<'a> {
};
(label, style)
});
let swap_text = app
.meminfo
.swap_used_pct()
.map(|p| format!("{p:.1}%"))
.unwrap_or_else(|| "n/a".into());
let wakeups_text = if app.wakeup_stats.system_per_second > 0.0 {
format!("{:.0}/s", app.wakeup_stats.system_per_second)
} else {
"n/a".into()
};
let lines = vec![
Line::from(vec![
"Vendor: ".set_style(theme.label),
@@ -229,6 +239,9 @@ pub fn render_header<'a>(app: &'a App, focused: bool) -> Paragraph<'a> {
" ".into(),
"P-state source: ".set_style(theme.label),
app.pss_source.as_str().into(),
" ".into(),
"Swap: ".set_style(theme.label),
swap_text.set_style(theme.value),
]),
Line::from(vec![
"PkgTherm: ".set_style(theme.label),
@@ -243,6 +256,9 @@ pub fn render_header<'a>(app: &'a App, focused: bool) -> Paragraph<'a> {
} else {
"ok".set_style(theme.value_ok)
},
" ".into(),
"Wakeups/s: ".set_style(theme.label),
wakeups_text.set_style(theme.value),
]),
Line::from(vec![
"SIMD: ".set_style(theme.label),
@@ -552,6 +568,66 @@ pub fn render_system_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> {
}
}
// htop/bottom-style per-CPU scheduler breakdown: cumulative time
// deltas rendered as state bars with queue depth / steals overlay.
let cpu_mix = crate::sched::time_breakdown_delta(
&app.prev_sched_breakdown,
&app.sched_stats.per_cpu_time_breakdown,
);
if !cpu_mix.is_empty() {
lines.push(Line::from(vec!["CPU states: ".set_style(theme.label)]));
for (idx, mix) in cpu_mix.iter().enumerate().take(app.cpus.len()) {
let user = mix.user.clamp(0.0, 100.0);
let system = mix.system.clamp(0.0, 100.0);
let idle = mix.idle.clamp(0.0, 100.0);
let io = mix.iowait.clamp(0.0, 100.0);
let active = (100.0 - idle).clamp(0.0, 100.0);
let bar = horizontal_bar(active as u8, 16);
let qd = app
.sched_stats
.per_cpu_queue_depth
.get(idx)
.copied()
.unwrap_or(0);
let steals = app
.sched_stats
.per_cpu_steals
.get(idx)
.copied()
.unwrap_or(0);
lines.push(Line::from(vec![
format!(" CPU{idx:<2} ").set_style(theme.label),
format!("[{bar}] ").set_style(theme.value),
format!(
"u{:>4.1}% s{:>4.1}% i{:>4.1}% io{:>4.1}% ",
user, system, idle, io
)
.set_style(theme.value),
format!("qd={qd} steals={steals}").set_style(theme.value_off),
]));
}
}
// C-state residency (powertop-style idle stats). Shown per CPU so
// the user can spot asymmetric package deep-sleep behaviour.
if !app.cpuidle_stats.per_cpu.is_empty() {
lines.push(Line::from(vec!["C-states: ".set_style(theme.label)]));
for cpu_id in app.cpuidle_stats.per_cpu.keys().take(app.cpus.len()) {
let mut parts: Vec<Span> = vec![format!(" CPU{cpu_id:<2} ").set_style(theme.label)];
if let Some(states) = app.cpuidle_stats.per_cpu.get(cpu_id) {
for s in states {
let pct = (s.percentage * 100.0).clamp(0.0, 100.0) as u8;
let bar = horizontal_bar(pct, 4);
parts.push(
format!("{}[{bar}]{:>4.1}% ", s.name, s.percentage * 100.0)
.set_style(theme.value),
);
}
}
lines.push(Line::from(parts));
}
}
if app.pkg_power_w.is_some()
|| app.pp0_power_w.is_some()
|| app.pp1_power_w.is_some()
@@ -2174,7 +2250,7 @@ pub fn render_keybar<'a>(app: &'a App) -> Paragraph<'a> {
let gov = format!(" g:{}", app.cpufreq.active.as_str());
let interval = format!(" [/]:{}ms", app.poll_ms);
let tab_hints: &str = if app.current_tab == crate::app::TabId::Process {
" ↑↓:sel k:kill N:nice a:affinity l:files C:cmd o:sort i:invert F:filter y:tree Enter:detail"
" ↑↓:sel k:kill N:nice a:affinity l:files C:cmd o:sort i:invert F:filter y:tree K:kernel Enter:detail"
} else {
" ↑↓:cpu p/P:±pstate m/M:min/max t:throttle r:refresh"
};
@@ -2297,6 +2373,7 @@ INTERACTIVE CONTROLS:
[y] toggle process tree view (Process tab)
[F] filter processes by name or PID; number alone jumps to PID (Process tab)
[h] toggle narrow process view (Process tab)
[K] toggle kernel-thread hiding (Process tab)
[o] cycle process sort mode
[i] invert sort direction
[Space] fold/unfold process subtree (tree mode)
@@ -14,6 +14,31 @@
use std::fs;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct CpuTimeBreakdown {
pub user: u64,
pub nice: u64,
pub system: u64,
pub idle: u64,
pub iowait: u64,
pub irq: u64,
pub softirq: u64,
pub steal: u64,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct CpuUsagePct {
pub total: f64,
pub user: f64,
pub nice: f64,
pub system: f64,
pub idle: f64,
pub iowait: f64,
pub irq: f64,
pub softirq: f64,
pub steal: f64,
}
/// Scheduler and IRQ statistics. Fields are `Option` because the
/// underlying data source may not provide all of them (Linux vs
/// Redox). `Default` gives all-`None` / empty, suitable as a
@@ -29,6 +54,7 @@ pub struct SchedStats {
pub per_cpu_switches: Vec<u64>,
pub per_cpu_steals: Vec<u64>,
pub per_cpu_queue_depth: Vec<u64>,
pub per_cpu_time_breakdown: Vec<CpuTimeBreakdown>,
}
impl SchedStats {
@@ -108,6 +134,25 @@ impl SchedStats {
let mut stats = Self::default();
for line in data.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("cpu") {
let mut parts = rest.split_whitespace();
let cpu_id = parts.next();
if cpu_id.is_some_and(|s| s.chars().all(|c| c.is_ascii_digit())) {
let values: Vec<u64> = parts.map(|s| s.parse().unwrap_or(0)).collect();
if values.len() >= 8 {
stats.per_cpu_time_breakdown.push(CpuTimeBreakdown {
user: values[0],
nice: values[1],
system: values[2],
idle: values[3],
iowait: values[4],
irq: values[5],
softirq: values[6],
steal: values[7],
});
}
}
}
// "ctxt <number>" — context switches since boot.
if let Some(rest) = trimmed.strip_prefix("ctxt ") {
stats.context_switches = rest.trim().parse().ok();
@@ -141,6 +186,56 @@ impl SchedStats {
}
}
pub fn breakdown_delta(prev: &[CpuTimeBreakdown], next: &[CpuTimeBreakdown]) -> Vec<CpuUsagePct> {
prev.iter()
.zip(next.iter())
.map(|(prev, next)| {
let user = next.user.saturating_sub(prev.user);
let nice = next.nice.saturating_sub(prev.nice);
let system = next.system.saturating_sub(prev.system);
let idle = next.idle.saturating_sub(prev.idle);
let iowait = next.iowait.saturating_sub(prev.iowait);
let irq = next.irq.saturating_sub(prev.irq);
let softirq = next.softirq.saturating_sub(prev.softirq);
let steal = next.steal.saturating_sub(prev.steal);
let total = user + nice + system + idle + iowait + irq + softirq + steal;
let pct = |v: u64| {
if total == 0 {
0.0
} else {
(v as f64) * 100.0 / (total as f64)
}
};
CpuUsagePct {
total: if total == 0 {
0.0
} else {
100.0 - pct(idle) - pct(iowait)
},
user: pct(user),
nice: pct(nice),
system: pct(system),
idle: pct(idle),
iowait: pct(iowait),
irq: pct(irq),
softirq: pct(softirq),
steal: pct(steal),
}
})
.collect()
}
/// Convert cumulative scheduler time counters into per-CPU percentages.
/// If the previous sample is missing or shorter than `next`, the missing
/// entries are skipped and the returned vector is truncated to the
/// shared prefix, mirroring the existing process of `zip`-based deltas.
pub fn time_breakdown_delta(
prev: &[CpuTimeBreakdown],
next: &[CpuTimeBreakdown],
) -> Vec<CpuUsagePct> {
breakdown_delta(prev, next)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -227,4 +322,85 @@ total switches 3000 steals 7
assert_eq!(stats.per_cpu_steals, vec![5, 2]);
assert_eq!(stats.per_cpu_queue_depth, vec![3, 1]);
}
#[test]
fn parse_linux_cpu_breakdowns() {
let data = "\
cpu 100 200 300 400 500 600 700 800
cpu0 10 20 30 40 50 60 70 80
cpu1 1 2 3 4 5 6 7 8
ctxt 1
";
let stats = SchedStats::parse_linux(data);
assert_eq!(stats.per_cpu_time_breakdown.len(), 2);
assert_eq!(stats.per_cpu_time_breakdown[0].user, 10);
assert_eq!(stats.per_cpu_time_breakdown[0].steal, 80);
assert_eq!(stats.per_cpu_time_breakdown[1].nice, 2);
}
#[test]
fn breakdown_delta_calculates_percentages() {
let prev = vec![CpuTimeBreakdown {
user: 10,
nice: 0,
system: 10,
idle: 80,
iowait: 0,
irq: 0,
softirq: 0,
steal: 0,
}];
let next = vec![CpuTimeBreakdown {
user: 30,
nice: 10,
system: 20,
idle: 100,
iowait: 5,
irq: 5,
softirq: 10,
steal: 0,
}];
let delta = breakdown_delta(&prev, &next);
assert_eq!(delta.len(), 1);
let d = &delta[0];
assert!((d.total - 68.75).abs() < 1e-9);
assert!((d.user - 25.0).abs() < 1e-9);
assert!((d.nice - 12.5).abs() < 1e-9);
assert!((d.system - 12.5).abs() < 1e-9);
assert!((d.idle - 25.0).abs() < 1e-9);
assert!((d.iowait - 6.25).abs() < 1e-9);
assert!((d.irq - 6.25).abs() < 1e-9);
assert!((d.softirq - 12.5).abs() < 1e-9);
}
#[test]
fn time_breakdown_delta_tracks_cpu_state_mix() {
let prev = vec![CpuTimeBreakdown {
user: 100,
nice: 10,
system: 30,
idle: 200,
iowait: 20,
irq: 4,
softirq: 6,
steal: 1,
}];
let next = vec![CpuTimeBreakdown {
user: 130,
nice: 10,
system: 40,
idle: 230,
iowait: 20,
irq: 8,
softirq: 10,
steal: 1,
}];
let pct = time_breakdown_delta(&prev, &next);
assert_eq!(pct.len(), 1);
assert!((pct[0].user - 38.461_538).abs() < 0.01);
assert!((pct[0].system - 12.820_512).abs() < 0.01);
assert!((pct[0].idle - 38.461_538).abs() < 0.01);
assert!((pct[0].irq - 5.128_205).abs() < 0.01);
assert!((pct[0].softirq - 5.128_205).abs() < 0.01);
}
}
@@ -89,6 +89,40 @@ fn read_sysfs_i64(path: &Path) -> Option<i64> {
read_sysfs(path)?.parse::<i64>().ok()
}
pub fn read_msr(cpu: u32, msr: u32) -> Option<u64> {
let path = format!("/scheme/sys/msr/{}/0x{:x}", cpu, msr);
let mut data = [0u8; 8];
let mut file = fs::File::open(path).ok()?;
use std::io::Read;
file.read_exact(&mut data).ok()?;
Some(u64::from_le_bytes(data))
}
pub fn read_cpu_temp_msr(cpu: u32) -> Option<f64> {
const IA32_THERM_STATUS: u32 = 0x19c;
const IA32_TEMPERATURE_TARGET: u32 = 0x1a2;
let therm_status = read_msr(cpu, IA32_THERM_STATUS)?;
if therm_status & (1 << 31) == 0 {
return None;
}
let digital_readout = ((therm_status >> 16) & 0x7f) as f64;
let tjmax = read_msr(cpu, IA32_TEMPERATURE_TARGET)
.map(|raw| ((raw >> 16) & 0xff) as f64)
.unwrap_or(100.0);
Some(tjmax - digital_readout)
}
fn read_first_cpu_temp_msr() -> Option<f64> {
for cpu in 0..256 {
if let Some(temp) = read_cpu_temp_msr(cpu) {
return Some(temp);
}
}
None
}
/// Read all `*_input` files in the chip directory, grouped by prefix.
fn read_chip_readings(chip_dir: &Path) -> Vec<SensorReading> {
let entries = match fs::read_dir(chip_dir) {
@@ -153,10 +187,24 @@ fn read_chip_readings(chip_dir: &Path) -> Vec<SensorReading> {
impl SensorInfo {
pub fn available() -> bool {
Path::new(SYS_HWMON).is_dir()
Path::new(SYS_HWMON).is_dir() || read_first_cpu_temp_msr().is_some()
}
pub fn read() -> Self {
let Ok(dirs) = fs::read_dir(SYS_HWMON) else {
if let Some(temp_c) = read_first_cpu_temp_msr() {
return Self {
chips: vec![HwmonChip {
name: "msr".to_string(),
path: PathBuf::from("/scheme/sys/msr"),
readings: vec![SensorReading {
kind: SensorKind::Temp,
label: Some("Package".to_string()),
raw_value: (temp_c * 1000.0).round() as i64,
display_value: format!("{:.1} °C", temp_c),
}],
}],
};
}
return Self::default();
};
let mut chips = Vec::new();
@@ -230,6 +278,13 @@ impl SensorInfo {
}
}
}
"msr" => {
for r in &chip.readings {
if r.kind == SensorKind::Temp && r.label.as_deref() == Some("Package") {
return Some((r.raw_value / 1000) as u32);
}
}
}
_ => {}
}
}
@@ -357,4 +412,20 @@ mod tests {
});
assert_eq!(info.pkg_temp_c(0), None);
}
#[test]
fn pkg_temp_c_from_msr_package_sensor() {
let mut info = SensorInfo::default();
info.chips.push(HwmonChip {
name: "msr".to_string(),
path: PathBuf::from("/scheme/sys/msr"),
readings: vec![SensorReading {
kind: SensorKind::Temp,
label: Some("Package".to_string()),
raw_value: 47000,
display_value: "47.0 °C".to_string(),
}],
});
assert_eq!(info.pkg_temp_c(0), Some(47));
}
}
@@ -51,6 +51,9 @@ pub struct SessionState {
/// Last refresh interval in milliseconds (0 = not set, use config/default).
#[serde(default)]
pub refresh_ms: u64,
/// Hide kernel threads in the Process tab.
#[serde(default)]
pub hide_kernel_threads: bool,
}
impl Default for SessionState {
@@ -65,6 +68,7 @@ impl Default for SessionState {
show_full_cmdline: false,
process_narrow: false,
refresh_ms: 0,
hide_kernel_threads: false,
}
}
}
@@ -178,6 +182,7 @@ mod tests {
show_full_cmdline: true,
process_narrow: true,
refresh_ms: 1_500,
hide_kernel_threads: false,
};
let serialized = toml::to_string(&s).unwrap();
let parsed: SessionState = toml::from_str(&serialized).unwrap();
@@ -239,6 +244,7 @@ mod tests {
show_full_cmdline: false,
process_narrow: false,
refresh_ms: 2_000,
hide_kernel_threads: false,
};
let serialized = toml::to_string(&s).unwrap();
// Mimic the temp+rename flow with a different name
@@ -0,0 +1,120 @@
use std::fs;
use std::time::Duration;
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct WakeupStats {
pub system_total: u64,
pub system_per_second: f64,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ProcessWakeupRate {
pub pid: u32,
pub wakeups: u64,
pub wakeups_per_second: f64,
}
fn dt_seconds(dt: Duration) -> f64 {
let secs = dt.as_secs_f64();
if secs > 0.0 { secs } else { 1.0 }
}
fn parse_intr_total(data: &str) -> Option<u64> {
data.lines().find_map(|line| {
let trimmed = line.trim_start();
let rest = trimmed.strip_prefix("intr ")?;
rest.split_whitespace().next()?.parse::<u64>().ok()
})
}
pub fn read_system_wakeups(prev_total: Option<u64>, dt: Duration) -> Option<WakeupStats> {
let data = fs::read_to_string("/proc/stat").ok()?;
let total = parse_intr_total(&data)?;
let delta = prev_total.map_or(0, |prev| total.saturating_sub(prev));
Some(WakeupStats {
system_total: total,
system_per_second: delta as f64 / dt_seconds(dt),
})
}
pub fn process_wakeups_per_sec(
prev: &[(u32, u64)],
next: &[(u32, u64)],
dt: Duration,
) -> Vec<ProcessWakeupRate> {
let dt = dt_seconds(dt);
next.iter()
.map(|&(pid, next_ctx)| {
let prev_ctx = prev
.iter()
.find(|&&(p, _)| p == pid)
.map(|&(_, c)| c)
.unwrap_or(0);
let wakeups = next_ctx.saturating_sub(prev_ctx);
ProcessWakeupRate {
pid,
wakeups,
wakeups_per_second: wakeups as f64 / dt,
}
})
.collect()
}
pub fn read_process_wakeups(pid: u32) -> Option<u64> {
let path = format!("/proc/{pid}/status");
let data = fs::read_to_string(path).ok()?;
let mut voluntary = None;
let mut nonvoluntary = None;
for line in data.lines() {
let trimmed = line.trim_start();
if let Some(rest) = trimmed.strip_prefix("voluntary_ctxt_switches:") {
voluntary = rest.trim().parse::<u64>().ok();
} else if let Some(rest) = trimmed.strip_prefix("nonvoluntary_ctxt_switches:") {
nonvoluntary = rest.trim().parse::<u64>().ok();
}
}
Some(voluntary.unwrap_or(0) + nonvoluntary.unwrap_or(0))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn system_delta_computation() {
assert_eq!(
parse_intr_total("cpu 1 2 3\nintr 12345 1 2 3\n"),
Some(12345)
);
let stats = read_system_wakeups_from_str(
"intr 12345 1 2 3\n",
Some(12000),
Duration::from_millis(500),
);
assert_eq!(stats.system_total, 12345);
assert_eq!(stats.system_per_second, 690.0);
}
#[test]
fn dt_fallback_uses_one_second() {
let rates = process_wakeups_per_sec(&[(1, 10)], &[(1, 13)], Duration::ZERO);
assert_eq!(rates[0].wakeups, 3);
assert_eq!(rates[0].wakeups_per_second, 3.0);
}
fn read_system_wakeups_from_str(
data: &str,
prev_total: Option<u64>,
dt: Duration,
) -> WakeupStats {
let total = parse_intr_total(data).unwrap();
let delta = prev_total.map_or(0, |prev| total.saturating_sub(prev));
WakeupStats {
system_total: total,
system_per_second: delta as f64 / dt_seconds(dt),
}
}
}