diff --git a/Cargo.lock b/Cargo.lock index 3086147a00..3e7322f003 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/local/recipes/system/redbear-ftdi/source/Cargo.toml b/local/recipes/system/redbear-ftdi/source/Cargo.toml index c0e0f50e05..8e75ef10db 100644 --- a/local/recipes/system/redbear-ftdi/source/Cargo.toml +++ b/local/recipes/system/redbear-ftdi/source/Cargo.toml @@ -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" } diff --git a/local/recipes/system/redbear-ftdi/source/src/main.rs b/local/recipes/system/redbear-ftdi/source/src/main.rs index 2f7ce8732b..2d7b5b47d6 100644 --- a/local/recipes/system/redbear-ftdi/source/src/main.rs +++ b/local/recipes/system/redbear-ftdi/source/src/main.rs @@ -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_ 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)); } + } } diff --git a/local/recipes/system/redbear-ftdi/source/src/scheme.rs b/local/recipes/system/redbear-ftdi/source/src/scheme.rs new file mode 100644 index 0000000000..1acbe51db0 --- /dev/null +++ b/local/recipes/system/redbear-ftdi/source/src/scheme.rs @@ -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, + bulk_out: Mutex, + open_count: Mutex, +} + +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 { + 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 { + 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 { + 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(()) + } +} diff --git a/local/recipes/system/redbear-power/source/Cargo.toml b/local/recipes/system/redbear-power/source/Cargo.toml index 6dfedd7081..79b6511b2f 100644 --- a/local/recipes/system/redbear-power/source/Cargo.toml +++ b/local/recipes/system/redbear-power/source/Cargo.toml @@ -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" \ No newline at end of file +libc = "0.2" diff --git a/local/recipes/system/redbear-power/source/src/app.rs b/local/recipes/system/redbear-power/source/src/app.rs index 4f8b1079ec..538d97a449 100644 --- a/local/recipes/system/redbear-power/source/src/app.rs +++ b/local/recipes/system/redbear-power/source/src/app.rs @@ -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, + /// 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, + /// 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> = 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 { + 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(); diff --git a/local/recipes/system/redbear-power/source/src/collector.rs b/local/recipes/system/redbear-power/source/src/collector.rs index bc161fe464..fae8f035c7 100644 --- a/local/recipes/system/redbear-power/source/src/collector.rs +++ b/local/recipes/system/redbear-power/source/src/collector.rs @@ -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::(), &mask) == 0 + sched_setaffinity(0, std::mem::size_of::(), &mask as *const u64) == 0 } #[cfg(not(target_os = "linux"))] { diff --git a/local/recipes/system/redbear-power/source/src/cpuidle.rs b/local/recipes/system/redbear-power/source/src/cpuidle.rs new file mode 100644 index 0000000000..b299a409e5 --- /dev/null +++ b/local/recipes/system/redbear-power/source/src/cpuidle.rs @@ -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>, +} + +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 { + let name = path.file_name()?.to_str()?; + name.strip_prefix("cpu")?.parse().ok() +} + +fn read_cpu_states(cpuidle_dir: &Path) -> Vec { + 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 { + 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::() - 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); + } +} diff --git a/local/recipes/system/redbear-power/source/src/kill.rs b/local/recipes/system/redbear-power/source/src/kill.rs index 36efcf4cfa..084805e5a5 100644 --- a/local/recipes/system/redbear-power/source/src/kill.rs +++ b/local/recipes/system/redbear-power/source/src/kill.rs @@ -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()) } } diff --git a/local/recipes/system/redbear-power/source/src/main.rs b/local/recipes/system/redbear-power/source/src/main.rs index ef3f9255cb..67fbf3e2d9 100644 --- a/local/recipes/system/redbear-power/source/src/main.rs +++ b/local/recipes/system/redbear-power/source/src/main.rs @@ -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!( diff --git a/local/recipes/system/redbear-power/source/src/meminfo.rs b/local/recipes/system/redbear-power/source/src/meminfo.rs index f7369c418d..44fcbf3937 100644 --- a/local/recipes/system/redbear-power/source/src/meminfo.rs +++ b/local/recipes/system/redbear-power/source/src/meminfo.rs @@ -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 { + 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); + } +} diff --git a/local/recipes/system/redbear-power/source/src/network.rs b/local/recipes/system/redbear-power/source/src/network.rs index 1e412d2736..a87a2002c3 100644 --- a/local/recipes/system/redbear-power/source/src/network.rs +++ b/local/recipes/system/redbear-power/source/src/network.rs @@ -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 { addrs } +#[cfg(target_os = "linux")] /// Read IPv4 addresses for a specific interface using libc getifaddrs. fn read_ipv4_addrs(iface_name: &str) -> Vec { let mut addrs = Vec::new(); @@ -156,6 +155,41 @@ fn read_ipv4_addrs(iface_name: &str) -> Vec { addrs } +#[cfg(target_os = "redox")] +fn read_trimmed(path: &Path) -> Option { + 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 { + read_trimmed(path)?.parse::().ok() +} + +#[cfg(target_os = "redox")] +fn read_ipv4_addrs(iface_name: &str) -> Vec { + 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 { Some(NetInterface { name: name.to_string(), @@ -179,9 +213,22 @@ fn read_interface(name: &str, path: &Path) -> Option { } 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 { diff --git a/local/recipes/system/redbear-power/source/src/process.rs b/local/recipes/system/redbear-power/source/src/process.rs index 907ab3cde2..db8305176e 100644 --- a/local/recipes/system/redbear-power/source/src/process.rs +++ b/local/recipes/system/redbear-power/source/src/process.rs @@ -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()) } } diff --git a/local/recipes/system/redbear-power/source/src/render.rs b/local/recipes/system/redbear-power/source/src/render.rs index 082b2234f5..0865566688 100644 --- a/local/recipes/system/redbear-power/source/src/render.rs +++ b/local/recipes/system/redbear-power/source/src/render.rs @@ -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 = 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) diff --git a/local/recipes/system/redbear-power/source/src/sched.rs b/local/recipes/system/redbear-power/source/src/sched.rs index 0d84ebeb14..a039c52f12 100644 --- a/local/recipes/system/redbear-power/source/src/sched.rs +++ b/local/recipes/system/redbear-power/source/src/sched.rs @@ -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, pub per_cpu_steals: Vec, pub per_cpu_queue_depth: Vec, + pub per_cpu_time_breakdown: Vec, } 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 = 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 " — 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 { + 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 { + 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); + } } diff --git a/local/recipes/system/redbear-power/source/src/sensor.rs b/local/recipes/system/redbear-power/source/src/sensor.rs index 3dc8f870c4..d3446a7428 100644 --- a/local/recipes/system/redbear-power/source/src/sensor.rs +++ b/local/recipes/system/redbear-power/source/src/sensor.rs @@ -89,6 +89,40 @@ fn read_sysfs_i64(path: &Path) -> Option { read_sysfs(path)?.parse::().ok() } +pub fn read_msr(cpu: u32, msr: u32) -> Option { + 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 { + 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 { + 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 { let entries = match fs::read_dir(chip_dir) { @@ -153,10 +187,24 @@ fn read_chip_readings(chip_dir: &Path) -> Vec { 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)); + } } diff --git a/local/recipes/system/redbear-power/source/src/session.rs b/local/recipes/system/redbear-power/source/src/session.rs index 7b2049424f..faa25312bb 100644 --- a/local/recipes/system/redbear-power/source/src/session.rs +++ b/local/recipes/system/redbear-power/source/src/session.rs @@ -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 diff --git a/local/recipes/system/redbear-power/source/src/wakeup.rs b/local/recipes/system/redbear-power/source/src/wakeup.rs new file mode 100644 index 0000000000..d1950c923e --- /dev/null +++ b/local/recipes/system/redbear-power/source/src/wakeup.rs @@ -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 { + data.lines().find_map(|line| { + let trimmed = line.trim_start(); + let rest = trimmed.strip_prefix("intr ")?; + rest.split_whitespace().next()?.parse::().ok() + }) +} + +pub fn read_system_wakeups(prev_total: Option, dt: Duration) -> Option { + 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 { + 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 { + 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::().ok(); + } else if let Some(rest) = trimmed.strip_prefix("nonvoluntary_ctxt_switches:") { + nonvoluntary = rest.trim().parse::().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, + 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), + } + } +}