9cd0a25906
v1.37 audit found 2 new bugs + recommended 5 v1.38 htop/btop-parity features. This release fixes both bugs and ships all 5 features. v1.37-0 (HIGH): set_tab() clears last_clicked_cpu The v1.37 re-click-to-expand feature set last_clicked_cpu on click but never reset it on tab switch. A user who clicked Per-CPU row 5, switched tabs, and came back would unexpectedly toggle expand. Fix: add App::set_tab(TabId) helper that resets both last_clicked_cpu and expanded_cpu, and route all 9 tab keys (1-9) + T through it. v1.37-1 (MEDIUM): mouse click respects filter The Process tab mouse click set process_cursor from the raw screen row, ignoring the active filter. With a filter active, the cursor highlight wouldn't align with the click, and right-click opened the wrong PID detail. Fix: new App::process_cursor_at_y(y, first_data_y) that walks the post-filter visible list and clamps to the last visible row. Wired into both left-click and right-click in handle_mouse. v1.38-2: SortDir + i key for direction toggle htop parity for the 'i' key. New App.sort_ascending: bool. The SortMode enum gets a new sort_ascending(procs, true) method (the existing sort() now delegates to sort_ascending(procs, false) for backward compat). On each refresh, if sort_ascending is true, the processes are re-sorted after the default descending pass. Press 'i' to flip; the status flash includes the current direction. v1.38-3: cmdline + io_priority in PID detail htop parity. New PidDetail.cmdline reads /proc/[pid]/cmdline, replaces NUL with space, strips trailing NULs. Rendered in the PID detail popup (truncated to 120 chars). New PidDetail.io_priority reads /proc/[pid]/stat field 47. Both are tolerant of missing files. v1.38-4: per-disk I/O throughput sparkline btop parity. New App.disk_history: BTreeMap<String, VecDeque<u8>> keyed by disk name. Mirrors the io_history pattern: each storage refresh collects raw kbps samples, normalizes per-disk against its own max, writes u8 to the public history. Rendered in the Storage tab as a 12-char sparkline next to each disk name. Reaps disks that have disappeared. Test count 140 -> 149 (+9): - set_tab_clears_last_clicked_cpu_and_expanded_cpu - process_cursor_at_y_respects_filter - process_cursor_at_y_clamps_to_last_visible - sort_ascending_flips_rss_order - read_cmdline_replaces_nul_with_space - read_cmdline_handles_missing_pid - read_io_priority_handles_self - read_io_priority_handles_missing_pid - update_disk_history_reaps_exited_disks Redox stripped binary: 4,348,776 bytes (+106 KiB from v1.37). Compile warnings: 56 (unchanged; all pre-existing).
1353 lines
46 KiB
Rust
1353 lines
46 KiB
Rust
//! Process list via `procfs` (`/proc/[pid]/stat` + `/proc/[pid]/comm`).
|
|
//!
|
|
//! Linux exposes per-process state, memory, and CPU usage via procfs.
|
|
//! `/proc/[pid]/stat` is a single space-separated line with 52 fields
|
|
//! per `man 5 proc`; the second field (comm) is wrapped in
|
|
//! parentheses and may contain spaces/parens, so it must be extracted
|
|
//! by locating the LAST `)` to handle names like `(bash)` or
|
|
//! `(Web Content)`.
|
|
//!
|
|
//! `/proc/[pid]/comm` is a separate file containing the process name
|
|
//! (truncated to 15 chars + newline) — used as a fallback if the
|
|
//! parens-parsing fails.
|
|
//!
|
|
//! On Redox, no equivalent scheme exists yet, so `read()` returns an
|
|
//! empty `ProcInfo` and the render layer shows
|
|
//! `(no processes detected)`.
|
|
|
|
use std::fs;
|
|
|
|
const MAX_PROCESSES: usize = 50;
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
|
pub enum SortMode {
|
|
#[default]
|
|
Rss,
|
|
Cpu,
|
|
Io,
|
|
IoRead,
|
|
IoWrite,
|
|
IoRate,
|
|
IoReadRate,
|
|
IoWriteRate,
|
|
RChar,
|
|
WChar,
|
|
VSize,
|
|
Pid,
|
|
Name,
|
|
}
|
|
|
|
impl SortMode {
|
|
pub fn next(self) -> Self {
|
|
match self {
|
|
SortMode::Rss => SortMode::Cpu,
|
|
SortMode::Cpu => SortMode::Io,
|
|
SortMode::Io => SortMode::IoRead,
|
|
SortMode::IoRead => SortMode::IoWrite,
|
|
SortMode::IoWrite => SortMode::IoRate,
|
|
SortMode::IoRate => SortMode::IoReadRate,
|
|
SortMode::IoReadRate => SortMode::IoWriteRate,
|
|
SortMode::IoWriteRate => SortMode::RChar,
|
|
SortMode::RChar => SortMode::WChar,
|
|
SortMode::WChar => SortMode::VSize,
|
|
SortMode::VSize => SortMode::Pid,
|
|
SortMode::Pid => SortMode::Name,
|
|
SortMode::Name => SortMode::Rss,
|
|
}
|
|
}
|
|
pub fn name(self) -> &'static str {
|
|
match self {
|
|
SortMode::Rss => "RSS",
|
|
SortMode::Cpu => "CPU%",
|
|
SortMode::Io => "IO",
|
|
SortMode::IoRead => "IO-R",
|
|
SortMode::IoWrite => "IO-W",
|
|
SortMode::IoRate => "IO/s",
|
|
SortMode::IoReadRate => "R/s",
|
|
SortMode::IoWriteRate => "W/s",
|
|
SortMode::RChar => "RChr",
|
|
SortMode::WChar => "WChr",
|
|
SortMode::VSize => "VSZ",
|
|
SortMode::Pid => "PID",
|
|
SortMode::Name => "Name",
|
|
}
|
|
}
|
|
pub fn sort(self, processes: &mut Vec<ProcessInfo>) {
|
|
// v1.38: direction is fixed at descending. The app
|
|
// calls `sort_ascending(processes, true)` for ascending
|
|
// sorts. Default false keeps backward compatibility for
|
|
// existing callers (tests, sort_tree, etc).
|
|
self.sort_ascending(processes, false)
|
|
}
|
|
|
|
/// Sort with explicit direction. `ascending = true` flips
|
|
/// the comparator for every sort mode. htop parity: the
|
|
/// `i` key toggles the App's `sort_ascending` flag and
|
|
/// re-sorts the visible processes.
|
|
pub fn sort_ascending(self, processes: &mut Vec<ProcessInfo>, ascending: bool) {
|
|
if ascending {
|
|
match self {
|
|
SortMode::Rss => processes.sort_by(|a, b| a.rss_kb.cmp(&b.rss_kb)),
|
|
SortMode::Cpu => processes.sort_by(|a, b| a.cpu_pct.partial_cmp(&b.cpu_pct).unwrap_or(std::cmp::Ordering::Equal)),
|
|
SortMode::Io => processes.sort_by(|a, b| {
|
|
let ai = a.io_total_kb();
|
|
let bi = b.io_total_kb();
|
|
match (ai, bi) {
|
|
(Some(x), Some(y)) => x.cmp(&y),
|
|
(Some(_), None) => std::cmp::Ordering::Less,
|
|
(None, Some(_)) => std::cmp::Ordering::Greater,
|
|
(None, None) => std::cmp::Ordering::Equal,
|
|
}
|
|
}),
|
|
SortMode::IoRead => sort_by_io_field_asc(processes, |p| p.io_read_kb),
|
|
SortMode::IoWrite => sort_by_io_field_asc(processes, |p| p.io_write_kb),
|
|
SortMode::IoRate => sort_by_io_rate_field_asc(processes, |p| p.io_total_rate_kbs()),
|
|
SortMode::IoReadRate => sort_by_io_rate_field_asc(processes, |p| p.io_read_rate_kbs),
|
|
SortMode::IoWriteRate => sort_by_io_rate_field_asc(processes, |p| p.io_write_rate_kbs),
|
|
SortMode::RChar => processes.sort_by(|a, b| a.io_rchar_kb.cmp(&b.io_rchar_kb)),
|
|
SortMode::WChar => processes.sort_by(|a, b| a.io_wchar_kb.cmp(&b.io_wchar_kb)),
|
|
SortMode::VSize => processes.sort_by(|a, b| a.vsize_kb.cmp(&b.vsize_kb)),
|
|
SortMode::Pid => processes.sort_by_key(|p| p.pid),
|
|
SortMode::Name => processes.sort_by(|a, b| a.comm.cmp(&b.comm)),
|
|
}
|
|
} else {
|
|
match self {
|
|
SortMode::Rss => processes.sort_by(|a, b| b.rss_kb.cmp(&a.rss_kb)),
|
|
SortMode::Cpu => processes.sort_by(|a, b| b.cpu_pct.partial_cmp(&a.cpu_pct).unwrap_or(std::cmp::Ordering::Equal)),
|
|
SortMode::Io => processes.sort_by(|a, b| {
|
|
let ai = a.io_total_kb();
|
|
let bi = b.io_total_kb();
|
|
match (ai, bi) {
|
|
(Some(x), Some(y)) => y.cmp(&x),
|
|
(Some(_), None) => std::cmp::Ordering::Less,
|
|
(None, Some(_)) => std::cmp::Ordering::Greater,
|
|
(None, None) => std::cmp::Ordering::Equal,
|
|
}
|
|
}),
|
|
SortMode::IoRead => sort_by_io_field(processes, |p| p.io_read_kb),
|
|
SortMode::IoWrite => sort_by_io_field(processes, |p| p.io_write_kb),
|
|
SortMode::IoRate => sort_by_io_rate_field(processes, |p| p.io_total_rate_kbs()),
|
|
SortMode::IoReadRate => sort_by_io_rate_field(processes, |p| p.io_read_rate_kbs),
|
|
SortMode::IoWriteRate => sort_by_io_rate_field(processes, |p| p.io_write_rate_kbs),
|
|
SortMode::RChar => processes.sort_by(|a, b| b.io_rchar_kb.cmp(&a.io_rchar_kb)),
|
|
SortMode::WChar => processes.sort_by(|a, b| b.io_wchar_kb.cmp(&a.io_wchar_kb)),
|
|
SortMode::VSize => processes.sort_by(|a, b| b.vsize_kb.cmp(&a.vsize_kb)),
|
|
SortMode::Pid => processes.sort_by_key(|p| p.pid),
|
|
SortMode::Name => processes.sort_by(|a, b| a.comm.cmp(&b.comm)),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn sort_by_io_field<F>(processes: &mut Vec<ProcessInfo>, field: F)
|
|
where
|
|
F: Fn(&ProcessInfo) -> Option<u64>,
|
|
{
|
|
processes.sort_by(|a, b| {
|
|
let ai = field(a);
|
|
let bi = field(b);
|
|
match (ai, bi) {
|
|
(Some(x), Some(y)) => y.cmp(&x),
|
|
(Some(_), None) => std::cmp::Ordering::Less,
|
|
(None, Some(_)) => std::cmp::Ordering::Greater,
|
|
(None, None) => std::cmp::Ordering::Equal,
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Ascending variant of `sort_by_io_field`. v1.38: paired
|
|
/// helpers (one for each direction) keep the per-direction
|
|
/// comparator logic local and avoid a runtime branch inside
|
|
/// the closure.
|
|
fn sort_by_io_field_asc<F>(processes: &mut Vec<ProcessInfo>, field: F)
|
|
where
|
|
F: Fn(&ProcessInfo) -> Option<u64>,
|
|
{
|
|
processes.sort_by(|a, b| {
|
|
let ai = field(a);
|
|
let bi = field(b);
|
|
match (ai, bi) {
|
|
(Some(x), Some(y)) => x.cmp(&y),
|
|
(Some(_), None) => std::cmp::Ordering::Less,
|
|
(None, Some(_)) => std::cmp::Ordering::Greater,
|
|
(None, None) => std::cmp::Ordering::Equal,
|
|
}
|
|
});
|
|
}
|
|
|
|
fn sort_by_io_rate_field<F>(processes: &mut Vec<ProcessInfo>, field: F)
|
|
where
|
|
F: Fn(&ProcessInfo) -> Option<f64>,
|
|
{
|
|
processes.sort_by(|a, b| {
|
|
let ai = field(a);
|
|
let bi = field(b);
|
|
match (ai, bi) {
|
|
(Some(x), Some(y)) => y.partial_cmp(&x).unwrap_or(std::cmp::Ordering::Equal),
|
|
(Some(_), None) => std::cmp::Ordering::Less,
|
|
(None, Some(_)) => std::cmp::Ordering::Greater,
|
|
(None, None) => std::cmp::Ordering::Equal,
|
|
}
|
|
});
|
|
}
|
|
|
|
fn sort_by_io_rate_field_asc<F>(processes: &mut Vec<ProcessInfo>, field: F)
|
|
where
|
|
F: Fn(&ProcessInfo) -> Option<f64>,
|
|
{
|
|
processes.sort_by(|a, b| {
|
|
let ai = field(a);
|
|
let bi = field(b);
|
|
match (ai, bi) {
|
|
(Some(x), Some(y)) => x.partial_cmp(&y).unwrap_or(std::cmp::Ordering::Equal),
|
|
(Some(_), None) => std::cmp::Ordering::Less,
|
|
(None, Some(_)) => std::cmp::Ordering::Greater,
|
|
(None, None) => std::cmp::Ordering::Equal,
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Tree sort: emit each process preceded by its parent(s) so the
|
|
/// visual reading order matches the parent-child hierarchy. Roots
|
|
/// (processes whose ppid is 0 or whose parent is not in the current
|
|
/// process set) are emitted first, then each root's descendants in
|
|
/// depth-first order. Within siblings, the `sort_mode` is honored.
|
|
///
|
|
/// The input `processes` is consumed and replaced; the output uses
|
|
/// the same ProcessInfo values. Stable for processes that share the
|
|
/// same parent and sort key.
|
|
///
|
|
/// Cycle protection: a PID that is its own ancestor is not recursed
|
|
/// into (its children are still emitted once as flat children of the
|
|
/// cycle parent). This handles the rare case of `init`-style PPID
|
|
/// loops in containers.
|
|
pub fn sort_tree(processes: &mut Vec<ProcessInfo>, sort_mode: SortMode) {
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
|
|
// 1. Index PIDs and group children by ppid.
|
|
let mut by_pid: BTreeMap<u32, usize> = BTreeMap::new();
|
|
for (i, p) in processes.iter().enumerate() {
|
|
by_pid.insert(p.pid, i);
|
|
}
|
|
let mut children: BTreeMap<u32, Vec<usize>> = BTreeMap::new();
|
|
for (i, p) in processes.iter().enumerate() {
|
|
children.entry(p.ppid).or_default().push(i);
|
|
}
|
|
|
|
// 2. Find roots: ppid == 0 or ppid not in pid set.
|
|
let mut roots: Vec<usize> = (0..processes.len())
|
|
.filter(|&i| {
|
|
let p = &processes[i];
|
|
p.ppid == 0 || !by_pid.contains_key(&p.ppid)
|
|
})
|
|
.collect();
|
|
|
|
// 3. Sort roots and each sibling group by sort_mode. We
|
|
// sort the indices using a small adapter closure so the
|
|
// existing `sort_mode.sort()` (which takes Vec<ProcessInfo>)
|
|
// can be reused.
|
|
let mut roots_proc: Vec<ProcessInfo> = roots.iter().map(|&i| processes[i].clone()).collect();
|
|
sort_mode.sort(&mut roots_proc);
|
|
roots = roots_proc.iter().map(|p| by_pid[&p.pid]).collect();
|
|
|
|
for v in children.values_mut() {
|
|
let mut v_proc: Vec<ProcessInfo> = v.iter().map(|&i| processes[i].clone()).collect();
|
|
sort_mode.sort(&mut v_proc);
|
|
*v = v_proc.iter().map(|p| by_pid[&p.pid]).collect();
|
|
}
|
|
|
|
// 4. DFS from each root, building the output in tree order.
|
|
let mut out: Vec<ProcessInfo> = Vec::with_capacity(processes.len());
|
|
let mut visited: BTreeSet<u32> = BTreeSet::new();
|
|
for &root in &roots {
|
|
dfs_emit(
|
|
&processes,
|
|
&children,
|
|
root,
|
|
&mut out,
|
|
&mut visited,
|
|
);
|
|
}
|
|
|
|
// 5. Append any leftover procs (defensive — should not happen
|
|
// given step 2, but handles e.g. a ppid cycle pointing back
|
|
// into the middle of the visited set).
|
|
for (i, p) in processes.iter().enumerate() {
|
|
if !visited.contains(&p.pid) {
|
|
out.push(processes[i].clone());
|
|
}
|
|
}
|
|
|
|
*processes = out;
|
|
}
|
|
|
|
/// Remove descendants of any PID in `folded` from a tree-ordered
|
|
/// process list. The parent of a folded PID stays visible (with a
|
|
/// `▶` indicator in the render layer). Cycles are tolerated: a PID
|
|
/// that is its own ancestor's descendant is still hidden if any
|
|
/// of its real ancestors are folded.
|
|
///
|
|
/// The input must be tree-ordered (i.e. produced by `sort_tree`).
|
|
/// The function uses a `BTreeSet` of "hidden ancestors": any PID
|
|
/// whose parent is in the set is also hidden. Roots are never
|
|
/// hidden.
|
|
pub fn apply_fold(processes: Vec<ProcessInfo>, folded: &std::collections::BTreeSet<u32>) -> Vec<ProcessInfo> {
|
|
if folded.is_empty() {
|
|
return processes;
|
|
}
|
|
let mut hidden: std::collections::BTreeSet<u32> = std::collections::BTreeSet::new();
|
|
let mut out: Vec<ProcessInfo> = Vec::with_capacity(processes.len());
|
|
for p in &processes {
|
|
// Hide if this PID's parent is hidden. Roots (ppid == 0 or
|
|
// ppid not in current set) are never hidden by this rule.
|
|
if p.ppid != 0 && hidden.contains(&p.ppid) {
|
|
hidden.insert(p.pid);
|
|
continue;
|
|
}
|
|
// If this PID itself is in the fold set, insert it into
|
|
// the hidden set so its children are skipped on the next
|
|
// iteration. The PID itself is still visible.
|
|
out.push(p.clone());
|
|
if folded.contains(&p.pid) {
|
|
hidden.insert(p.pid);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn dfs_emit(
|
|
processes: &[ProcessInfo],
|
|
children: &std::collections::BTreeMap<u32, Vec<usize>>,
|
|
idx: usize,
|
|
out: &mut Vec<ProcessInfo>,
|
|
visited: &mut std::collections::BTreeSet<u32>,
|
|
) {
|
|
let pid = processes[idx].pid;
|
|
if !visited.insert(pid) {
|
|
return; // cycle protection
|
|
}
|
|
out.push(processes[idx].clone());
|
|
if let Some(kids) = children.get(&pid) {
|
|
for &k in kids {
|
|
dfs_emit(processes, children, k, out, visited);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Default, Clone, Debug)]
|
|
pub struct ProcessInfo {
|
|
pub pid: u32,
|
|
pub comm: String,
|
|
pub state: char,
|
|
/// Parent PID. Read by `sort_tree` (v1.27+) and `tree_prefix`
|
|
/// to build the parent-child ordering in tree view.
|
|
pub ppid: u32,
|
|
pub utime: u64,
|
|
pub stime: u64,
|
|
pub priority: i64,
|
|
pub nice: i64,
|
|
pub num_threads: i64,
|
|
/// Virtual address-space size in KiB. Read by `SortMode::VSize`
|
|
/// (v1.28+) to sort by VSZ. Note: VSZ includes the entire
|
|
/// mapped address space (mmap'd libraries, heap, stack,
|
|
/// reserved-but-uncommitted) and is often much larger than
|
|
/// RSS. Useful for "who is using the most address space" but
|
|
/// NOT for "who is using the most physical memory" (use RSS
|
|
/// for that).
|
|
pub vsize_kb: u64,
|
|
pub rss_kb: u64,
|
|
pub cpu_pct: f64,
|
|
/// Cumulative read bytes (KiB) from `/proc/[pid]/io:read_bytes`.
|
|
/// `None` when the file is missing or the field is absent (e.g.
|
|
/// process just exited, or `/proc/[pid]/io` requires
|
|
/// `CAP_SYS_PTRACE` for an owned UID on Linux, or the proc
|
|
/// scheme on Redox does not expose IO stats for this PID).
|
|
pub io_read_kb: Option<u64>,
|
|
/// Cumulative write bytes (KiB) from
|
|
/// `/proc/[pid]/io:write_bytes`. Same caveats as `io_read_kb`.
|
|
pub io_write_kb: Option<u64>,
|
|
/// Cumulative `rchar` bytes (KiB) from `/proc/[pid]/io:rchar`.
|
|
/// VFS-level read byte count (includes cache hits, tty, etc).
|
|
/// Always Some — defaults to 0 if the field is absent on the
|
|
/// kernel (very old kernels).
|
|
pub io_rchar_kb: u64,
|
|
/// Cumulative `wchar` bytes (KiB) from `/proc/[pid]/io:wchar`.
|
|
/// VFS-level write byte count. Always Some.
|
|
pub io_wchar_kb: u64,
|
|
/// Read throughput (KiB/s) computed as the delta of `io_read_kb`
|
|
/// across two reads divided by `dt_secs`. `None` when the prev
|
|
/// read is missing (first sample after startup) or when
|
|
/// `io_read_kb` is `None` for either prev or current.
|
|
pub io_read_rate_kbs: Option<f64>,
|
|
/// Write throughput (KiB/s) computed as the delta of
|
|
/// `io_write_kb` across two reads divided by `dt_secs`. Same
|
|
/// sentinel semantics as `io_read_rate_kbs`.
|
|
pub io_write_rate_kbs: Option<f64>,
|
|
}
|
|
|
|
impl ProcessInfo {
|
|
pub fn total_cpu_ticks(&self) -> u64 {
|
|
self.utime.saturating_add(self.stime)
|
|
}
|
|
|
|
/// Total IO bytes (read + write) in KiB. Returns `None` if either
|
|
/// field is `None` — the panel renders the row as `—` instead of
|
|
/// silently zeroing a hidden counter. Used by `SortMode::Io` and
|
|
/// by the IO column renderer.
|
|
pub fn io_total_kb(&self) -> Option<u64> {
|
|
match (self.io_read_kb, self.io_write_kb) {
|
|
(Some(r), Some(w)) => Some(r.saturating_add(w)),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Total IO throughput (read + write) in KiB/s. Returns `None`
|
|
/// if either rate field is `None`. Used by `SortMode::IoRate`.
|
|
pub fn io_total_rate_kbs(&self) -> Option<f64> {
|
|
match (self.io_read_rate_kbs, self.io_write_rate_kbs) {
|
|
(Some(r), Some(w)) => Some(r + w),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn format_memory_kb(kb: u64) -> String {
|
|
const UNITS: &[&str] = &["KiB", "MiB", "GiB", "TiB"];
|
|
let mut value = kb as f64;
|
|
let mut unit_idx = 0;
|
|
while value >= 1024.0 && unit_idx < UNITS.len() - 1 {
|
|
value /= 1024.0;
|
|
unit_idx += 1;
|
|
}
|
|
format!("{:.1} {}", value, UNITS[unit_idx])
|
|
}
|
|
|
|
/// Format a rate (KiB/s) with human-friendly units. Uses
|
|
/// 1024-base binary units (KiB/s, MiB/s, GiB/s) for consistency
|
|
/// with `format_memory_kb`. Negative inputs are clamped to 0
|
|
/// (saturating) — a "negative rate" is meaningless and indicates
|
|
/// a clock-reset or test fixture edge case.
|
|
pub fn format_rate_kbs(kbs: f64) -> String {
|
|
const UNITS: &[&str] = &["KiB/s", "MiB/s", "GiB/s", "TiB/s"];
|
|
let mut value = kbs.max(0.0);
|
|
let mut unit_idx = 0;
|
|
while value >= 1024.0 && unit_idx < UNITS.len() - 1 {
|
|
value /= 1024.0;
|
|
unit_idx += 1;
|
|
}
|
|
format!("{:.1} {}", value, UNITS[unit_idx])
|
|
}
|
|
}
|
|
|
|
#[derive(Default, Clone, Debug)]
|
|
pub struct ProcInfo {
|
|
pub processes: Vec<ProcessInfo>,
|
|
pub total_memory_kb: u64,
|
|
pub total_count: usize,
|
|
}
|
|
|
|
fn read_comm(pid: u32) -> String {
|
|
fs::read_to_string(format!("/proc/{}/comm", pid))
|
|
.ok()
|
|
.map(|s| s.trim().to_string())
|
|
.unwrap_or_else(|| "?".to_string())
|
|
}
|
|
|
|
/// Parse `/proc/[pid]/io` content and return the four fields
|
|
/// we care about: (read_bytes, write_bytes, rchar, wchar). The
|
|
/// two `bytes` fields are the I/O actually performed (via
|
|
/// read/write syscalls that hit storage); `rchar`/`wchar` are
|
|
/// the cumulative byte counts the kernel's VFS layer saw
|
|
/// (includes reads/writes served from page cache, terminal
|
|
/// output, etc.). Returns `None` if the file cannot be read.
|
|
/// `read_bytes`/`write_bytes` are mandatory; `rchar`/`wchar`
|
|
/// may be absent on older kernels — callers default to 0 in
|
|
/// that case.
|
|
fn read_io_file(pid: u32) -> Option<(u64, u64, u64, u64)> {
|
|
let content = fs::read_to_string(format!("/proc/{pid}/io")).ok()?;
|
|
let mut read: Option<u64> = None;
|
|
let mut write: Option<u64> = None;
|
|
let mut rchar: u64 = 0;
|
|
let mut wchar: u64 = 0;
|
|
for line in content.lines() {
|
|
if let Some(rest) = line.strip_prefix("read_bytes:") {
|
|
read = rest.trim().parse::<u64>().ok();
|
|
} else if let Some(rest) = line.strip_prefix("write_bytes:") {
|
|
write = rest.trim().parse::<u64>().ok();
|
|
} else if let Some(rest) = line.strip_prefix("rchar:") {
|
|
rchar = rest.trim().parse::<u64>().unwrap_or(0);
|
|
} else if let Some(rest) = line.strip_prefix("wchar:") {
|
|
wchar = rest.trim().parse::<u64>().unwrap_or(0);
|
|
}
|
|
}
|
|
Some((read?, write?, rchar, wchar))
|
|
}
|
|
|
|
/// Compute KiB/s rate from a prev/current sample pair. Returns `None`
|
|
/// when either sample is `None` (process just started, /proc/[pid]/io
|
|
/// became readable/unreadable, or first sample after startup) or
|
|
/// when `dt_secs <= 0` (clock skew or test fixture). `saturating_sub`
|
|
/// handles the (impossible in practice) clock-reset case.
|
|
fn compute_rate_kbs(prev: Option<u64>, now: Option<u64>, dt_secs: f64) -> Option<f64> {
|
|
if dt_secs <= 0.0 {
|
|
return None;
|
|
}
|
|
let (p, n) = (prev?, now?);
|
|
let delta_kb = n.saturating_sub(p) as f64;
|
|
Some(delta_kb / dt_secs)
|
|
}
|
|
|
|
fn parse_stat_line(line: &str) -> Option<ProcessInfo> {
|
|
let open = line.find('(')?;
|
|
let close = line.rfind(')')?;
|
|
if close <= open {
|
|
return None;
|
|
}
|
|
let comm = line[open + 1..close].to_string();
|
|
let tail = &line[close + 1..];
|
|
let fields: Vec<&str> = tail.split_whitespace().collect();
|
|
if fields.len() < 22 {
|
|
return None;
|
|
}
|
|
let pid: u32 = line[..open].trim().parse().ok()?;
|
|
let state_char = fields[0].chars().next().unwrap_or('?');
|
|
let ppid: u32 = fields[1].parse().ok()?;
|
|
let utime: u64 = fields[11].parse().ok()?;
|
|
let stime: u64 = fields[12].parse().ok()?;
|
|
let priority: i64 = fields[15].parse().ok()?;
|
|
let nice: i64 = fields[16].parse().ok()?;
|
|
let num_threads: i64 = fields[17].parse().ok()?;
|
|
let vsize_bytes: i64 = fields[20].parse().ok()?;
|
|
let rss_pages: i64 = fields[21].parse().ok()?;
|
|
let (io_read_bytes, io_write_bytes, rchar_bytes, wchar_bytes) = match read_io_file(pid) {
|
|
Some((r, w, rc, wc)) => (Some(r / 1024), Some(w / 1024), rc / 1024, wc / 1024),
|
|
None => (None, None, 0, 0),
|
|
};
|
|
Some(ProcessInfo {
|
|
pid,
|
|
comm,
|
|
state: state_char,
|
|
ppid,
|
|
utime,
|
|
stime,
|
|
priority,
|
|
nice,
|
|
num_threads,
|
|
vsize_kb: (vsize_bytes.max(0) as u64) / 1024,
|
|
rss_kb: (rss_pages.max(0) as u64) * 4,
|
|
cpu_pct: 0.0,
|
|
io_read_kb: io_read_bytes,
|
|
io_write_kb: io_write_bytes,
|
|
io_rchar_kb: rchar_bytes,
|
|
io_wchar_kb: wchar_bytes,
|
|
io_read_rate_kbs: None,
|
|
io_write_rate_kbs: None,
|
|
})
|
|
}
|
|
|
|
fn read_process(pid: u32) -> Option<ProcessInfo> {
|
|
let stat = fs::read_to_string(format!("/proc/{}/stat", pid)).ok()?;
|
|
let parsed = parse_stat_line(&stat)?;
|
|
let mut info = parsed;
|
|
if info.comm.is_empty() || info.comm == "?" {
|
|
info.comm = read_comm(pid);
|
|
}
|
|
Some(info)
|
|
}
|
|
|
|
impl ProcInfo {
|
|
pub fn read() -> Self {
|
|
Self::read_sorted(SortMode::default())
|
|
}
|
|
pub fn read_sorted(sort_mode: SortMode) -> Self {
|
|
let Ok(entries) = fs::read_dir("/proc") else { return Self::default(); };
|
|
let mut processes = Vec::new();
|
|
let mut pids: Vec<u32> = Vec::new();
|
|
for entry in entries.flatten() {
|
|
let name = entry.file_name();
|
|
let name_str = match name.to_str() {
|
|
Some(s) => s,
|
|
None => continue,
|
|
};
|
|
if let Ok(pid) = name_str.parse::<u32>() {
|
|
pids.push(pid);
|
|
}
|
|
}
|
|
let total_count = pids.len();
|
|
for pid in pids {
|
|
if let Some(proc) = read_process(pid) {
|
|
processes.push(proc);
|
|
}
|
|
}
|
|
sort_mode.sort(&mut processes);
|
|
processes.truncate(MAX_PROCESSES);
|
|
let total_memory_kb: u64 = processes.iter().map(|p| p.rss_kb).sum();
|
|
Self { processes, total_memory_kb, total_count }
|
|
}
|
|
|
|
/// Read processes and compute CPU% and IO rates for each based on
|
|
/// the delta vs the previous read. `dt_secs` is wall-clock elapsed
|
|
/// since previous read; `num_cpus` is used to normalize per-CPU.
|
|
/// IO rate is computed in KiB/s from the delta of `/proc/[pid]/io`
|
|
/// read_bytes/write_bytes divided by `dt_secs`.
|
|
pub fn read_with_cpu_pct_sorted(prev: &ProcInfo, dt_secs: f64, num_cpus: u64, sort_mode: SortMode) -> Self {
|
|
let mut info = Self::read_sorted(sort_mode);
|
|
if dt_secs <= 0.0 || num_cpus == 0 {
|
|
return info;
|
|
}
|
|
for p in &mut info.processes {
|
|
let prev_p = prev
|
|
.processes
|
|
.iter()
|
|
.find(|q| q.pid == p.pid);
|
|
if let Some(pp) = prev_p {
|
|
let prev_ticks = pp.total_cpu_ticks();
|
|
let now_ticks = p.total_cpu_ticks();
|
|
let delta = now_ticks.saturating_sub(prev_ticks) as f64;
|
|
let ticks_per_sec = delta / dt_secs;
|
|
p.cpu_pct = (ticks_per_sec / num_cpus as f64) * 100.0;
|
|
p.io_read_rate_kbs = compute_rate_kbs(pp.io_read_kb, p.io_read_kb, dt_secs);
|
|
p.io_write_rate_kbs = compute_rate_kbs(pp.io_write_kb, p.io_write_kb, dt_secs);
|
|
}
|
|
}
|
|
// Re-sort because CPU% values may have changed
|
|
sort_mode.sort(&mut info.processes);
|
|
info
|
|
}
|
|
pub fn is_empty(&self) -> bool {
|
|
self.processes.is_empty()
|
|
}
|
|
pub fn count(&self) -> usize {
|
|
self.processes.len()
|
|
}
|
|
}
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn format_memory_below_1kib() {
|
|
assert_eq!(ProcessInfo::format_memory_kb(500), "500.0 KiB");
|
|
}
|
|
|
|
#[test]
|
|
fn format_memory_1mib() {
|
|
assert_eq!(ProcessInfo::format_memory_kb(1024), "1.0 MiB");
|
|
}
|
|
|
|
#[test]
|
|
fn format_memory_1gib() {
|
|
assert_eq!(ProcessInfo::format_memory_kb(1024 * 1024), "1.0 GiB");
|
|
}
|
|
|
|
#[test]
|
|
fn format_rate_below_1kibs() {
|
|
assert_eq!(ProcessInfo::format_rate_kbs(500.0), "500.0 KiB/s");
|
|
}
|
|
|
|
#[test]
|
|
fn format_rate_1mibs() {
|
|
assert_eq!(ProcessInfo::format_rate_kbs(1024.0), "1.0 MiB/s");
|
|
}
|
|
|
|
#[test]
|
|
fn format_rate_1gibs() {
|
|
assert_eq!(ProcessInfo::format_rate_kbs(1024.0 * 1024.0), "1.0 GiB/s");
|
|
}
|
|
|
|
#[test]
|
|
fn format_rate_saturates_negative_to_zero() {
|
|
assert_eq!(ProcessInfo::format_rate_kbs(-100.0), "0.0 KiB/s");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_stat_line_valid() {
|
|
// bash process: pid=1 (comm) S ppid pgrp session ...
|
|
let line = "2642164 (bash) S 3317951 2642164 2642164 0 -1 4194304 229 451 0 2 0 0 0 0 20 0 1 0 138471094 8060928 883 18446744073709551615 94645830324224 94645831153721 140735825184736 0 0 0 65536 4 65538 1 0 0 17 0 0 0";
|
|
let p = parse_stat_line(line).expect("should parse");
|
|
assert_eq!(p.pid, 2642164);
|
|
assert_eq!(p.comm, "bash");
|
|
assert_eq!(p.state, 'S');
|
|
assert_eq!(p.ppid, 3317951);
|
|
assert_eq!(p.nice, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_stat_line_handles_spaces_in_comm() {
|
|
// Firefox process with "(Web Content)" comm
|
|
let line = "12345 (Web Content) R 1 12345 12345 0 -1 1077936384 100 0 0 0 5 0 0 0 20 0 1 0 1000 1000 100 18446744073709551615 1 1 0 0 0 0 0 0 0 0 0 0 0 0 17 0 0 0";
|
|
let p = parse_stat_line(line).expect("should parse");
|
|
assert_eq!(p.comm, "Web Content");
|
|
assert_eq!(p.state, 'R');
|
|
}
|
|
|
|
#[test]
|
|
fn parse_stat_line_missing_parens() {
|
|
assert!(parse_stat_line("invalid").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_stat_line_too_few_fields() {
|
|
let line = "1 (x) S";
|
|
assert!(parse_stat_line(line).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn proc_info_is_empty_when_no_proc() {
|
|
let info = ProcInfo::default();
|
|
assert!(info.is_empty());
|
|
assert_eq!(info.count(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn process_total_cpu_ticks() {
|
|
let p = ProcessInfo { utime: 100, stime: 50, ..Default::default() };
|
|
assert_eq!(p.total_cpu_ticks(), 150);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod cpu_pct_unit_tests {
|
|
use super::*;
|
|
|
|
fn make_proc(pid: u32, utime: u64, stime: u64) -> ProcessInfo {
|
|
ProcessInfo { pid, utime, stime, cpu_pct: 0.0, ..Default::default() }
|
|
}
|
|
|
|
#[test]
|
|
fn cpu_pct_delta_formula() {
|
|
let prev_ticks = make_proc(1, 100, 50).total_cpu_ticks();
|
|
let now_ticks = make_proc(1, 200, 80).total_cpu_ticks();
|
|
let delta = now_ticks.saturating_sub(prev_ticks) as f64;
|
|
let cpu_pct = (delta / 2.0 / 4.0) * 100.0;
|
|
assert_eq!(cpu_pct, 1625.0);
|
|
}
|
|
|
|
#[test]
|
|
fn cpu_pct_zero_delta() {
|
|
let prev_ticks = make_proc(1, 100, 50).total_cpu_ticks();
|
|
let now_ticks = make_proc(1, 100, 50).total_cpu_ticks();
|
|
let delta = now_ticks.saturating_sub(prev_ticks) as f64;
|
|
let cpu_pct = (delta / 2.0 / 4.0) * 100.0;
|
|
assert_eq!(cpu_pct, 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn cpu_pct_saturating_sub_underflow() {
|
|
let now = make_proc(1, 50, 25);
|
|
let prev = make_proc(1, 100, 100);
|
|
let delta = now.total_cpu_ticks().saturating_sub(prev.total_cpu_ticks());
|
|
assert_eq!(delta, 0);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod sort_unit_tests {
|
|
use super::*;
|
|
|
|
fn make_proc(pid: u32, rss: u64, cpu: f64, name: &str) -> ProcessInfo {
|
|
ProcessInfo {
|
|
pid,
|
|
comm: name.to_string(),
|
|
rss_kb: rss,
|
|
cpu_pct: cpu,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn sort_default_is_rss_descending() {
|
|
assert_eq!(SortMode::default(), SortMode::Rss);
|
|
}
|
|
|
|
#[test]
|
|
fn sort_cycle() {
|
|
assert_eq!(SortMode::Rss.next(), SortMode::Cpu);
|
|
assert_eq!(SortMode::Cpu.next(), SortMode::Io);
|
|
assert_eq!(SortMode::Io.next(), SortMode::IoRead);
|
|
assert_eq!(SortMode::IoRead.next(), SortMode::IoWrite);
|
|
assert_eq!(SortMode::IoWrite.next(), SortMode::IoRate);
|
|
assert_eq!(SortMode::IoRate.next(), SortMode::IoReadRate);
|
|
assert_eq!(SortMode::IoReadRate.next(), SortMode::IoWriteRate);
|
|
assert_eq!(SortMode::IoWriteRate.next(), SortMode::RChar);
|
|
assert_eq!(SortMode::RChar.next(), SortMode::WChar);
|
|
assert_eq!(SortMode::WChar.next(), SortMode::VSize);
|
|
assert_eq!(SortMode::VSize.next(), SortMode::Pid);
|
|
assert_eq!(SortMode::Pid.next(), SortMode::Name);
|
|
assert_eq!(SortMode::Name.next(), SortMode::Rss);
|
|
}
|
|
|
|
#[test]
|
|
fn sort_by_rss_descending() {
|
|
let mut ps = vec![
|
|
make_proc(1, 100, 0.0, "a"),
|
|
make_proc(2, 500, 0.0, "b"),
|
|
make_proc(3, 300, 0.0, "c"),
|
|
];
|
|
SortMode::Rss.sort(&mut ps);
|
|
assert_eq!(ps[0].pid, 2);
|
|
assert_eq!(ps[1].pid, 3);
|
|
assert_eq!(ps[2].pid, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn sort_by_cpu_descending() {
|
|
let mut ps = vec![
|
|
make_proc(1, 0, 10.0, "a"),
|
|
make_proc(2, 0, 50.0, "b"),
|
|
make_proc(3, 0, 30.0, "c"),
|
|
];
|
|
SortMode::Cpu.sort(&mut ps);
|
|
assert_eq!(ps[0].pid, 2);
|
|
assert_eq!(ps[1].pid, 3);
|
|
assert_eq!(ps[2].pid, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn sort_by_pid_ascending() {
|
|
let mut ps = vec![
|
|
make_proc(3, 0, 0.0, "a"),
|
|
make_proc(1, 0, 0.0, "b"),
|
|
make_proc(2, 0, 0.0, "c"),
|
|
];
|
|
SortMode::Pid.sort(&mut ps);
|
|
assert_eq!(ps[0].pid, 1);
|
|
assert_eq!(ps[1].pid, 2);
|
|
assert_eq!(ps[2].pid, 3);
|
|
}
|
|
|
|
#[test]
|
|
fn sort_by_name_alphabetical() {
|
|
let mut ps = vec![
|
|
make_proc(1, 0, 0.0, "zsh"),
|
|
make_proc(2, 0, 0.0, "bash"),
|
|
make_proc(3, 0, 0.0, "firefox"),
|
|
];
|
|
SortMode::Name.sort(&mut ps);
|
|
assert_eq!(ps[0].comm, "bash");
|
|
assert_eq!(ps[1].comm, "firefox");
|
|
assert_eq!(ps[2].comm, "zsh");
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod filter_unit_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn filter_case_insensitive() {
|
|
let needle = "FIREFOX";
|
|
let haystack = "firefox";
|
|
assert!(haystack.to_lowercase().contains(&needle.to_lowercase()));
|
|
}
|
|
|
|
#[test]
|
|
fn filter_substring_match() {
|
|
let needle = "fox";
|
|
let haystack = "firefox";
|
|
assert!(haystack.contains(needle));
|
|
}
|
|
|
|
#[test]
|
|
fn filter_no_match() {
|
|
let needle = "nonexistent";
|
|
let haystack = "firefox";
|
|
assert!(!haystack.contains(needle));
|
|
}
|
|
|
|
#[test]
|
|
fn filter_empty_needle_matches_all() {
|
|
let needle = "";
|
|
let hay = "firefox";
|
|
assert!(hay.contains(needle));
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod io_sort_unit_tests {
|
|
use super::*;
|
|
|
|
fn make_proc(pid: u32, io_read: u64, io_write: u64) -> ProcessInfo {
|
|
ProcessInfo {
|
|
pid,
|
|
io_read_kb: Some(io_read),
|
|
io_write_kb: Some(io_write),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn make_proc_none(pid: u32) -> ProcessInfo {
|
|
ProcessInfo {
|
|
pid,
|
|
io_read_kb: None,
|
|
io_write_kb: None,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn io_total_sums_read_write() {
|
|
let p = make_proc(1, 100, 50);
|
|
assert_eq!(p.io_total_kb(), Some(150));
|
|
}
|
|
|
|
#[test]
|
|
fn io_total_saturates_at_u64_max() {
|
|
let mut p = make_proc(1, u64::MAX, 0);
|
|
p.io_read_kb = Some(u64::MAX);
|
|
p.io_write_kb = Some(1);
|
|
assert_eq!(p.io_total_kb(), Some(u64::MAX));
|
|
}
|
|
|
|
#[test]
|
|
fn io_total_returns_none_when_fields_missing() {
|
|
let p = make_proc_none(1);
|
|
assert_eq!(p.io_total_kb(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn sort_by_io_descending() {
|
|
let mut ps = vec![
|
|
make_proc(1, 100, 0),
|
|
make_proc(2, 0, 500),
|
|
make_proc(3, 200, 200),
|
|
];
|
|
SortMode::Io.sort(&mut ps);
|
|
assert_eq!(ps[0].pid, 2); // total 500
|
|
assert_eq!(ps[1].pid, 3); // total 400
|
|
assert_eq!(ps[2].pid, 1); // total 100
|
|
}
|
|
|
|
#[test]
|
|
fn sort_by_io_pushes_missing_to_bottom() {
|
|
let mut ps = vec![
|
|
make_proc_none(1),
|
|
make_proc(2, 0, 100),
|
|
make_proc_none(3),
|
|
make_proc(4, 50, 50),
|
|
];
|
|
SortMode::Io.sort(&mut ps);
|
|
assert_eq!(ps[0].pid, 2); // 100 KiB
|
|
assert_eq!(ps[1].pid, 4); // 100 KiB (tie — stable sort, by pid)
|
|
// Remaining two are None; stable sort preserves original order
|
|
// (pid 1, 3 in the input).
|
|
assert_eq!(ps[2].pid, 1);
|
|
assert_eq!(ps[3].pid, 3);
|
|
}
|
|
|
|
#[test]
|
|
fn sort_cycle_includes_io() {
|
|
assert_eq!(SortMode::Rss.next(), SortMode::Cpu);
|
|
assert_eq!(SortMode::Cpu.next(), SortMode::Io);
|
|
assert_eq!(SortMode::Io.next(), SortMode::IoRead);
|
|
assert_eq!(SortMode::IoRead.next(), SortMode::IoWrite);
|
|
assert_eq!(SortMode::IoWrite.next(), SortMode::IoRate);
|
|
assert_eq!(SortMode::IoRate.next(), SortMode::IoReadRate);
|
|
assert_eq!(SortMode::IoReadRate.next(), SortMode::IoWriteRate);
|
|
assert_eq!(SortMode::IoWriteRate.next(), SortMode::RChar);
|
|
assert_eq!(SortMode::RChar.next(), SortMode::WChar);
|
|
assert_eq!(SortMode::WChar.next(), SortMode::VSize);
|
|
assert_eq!(SortMode::VSize.next(), SortMode::Pid);
|
|
assert_eq!(SortMode::Pid.next(), SortMode::Name);
|
|
assert_eq!(SortMode::Name.next(), SortMode::Rss);
|
|
}
|
|
|
|
#[test]
|
|
fn io_name_is_io() {
|
|
assert_eq!(SortMode::Io.name(), "IO");
|
|
assert_eq!(SortMode::IoRead.name(), "IO-R");
|
|
assert_eq!(SortMode::IoWrite.name(), "IO-W");
|
|
assert_eq!(SortMode::IoRate.name(), "IO/s");
|
|
assert_eq!(SortMode::IoReadRate.name(), "R/s");
|
|
assert_eq!(SortMode::IoWriteRate.name(), "W/s");
|
|
assert_eq!(SortMode::RChar.name(), "RChr");
|
|
assert_eq!(SortMode::WChar.name(), "WChr");
|
|
assert_eq!(SortMode::VSize.name(), "VSZ");
|
|
}
|
|
|
|
#[test]
|
|
fn sort_by_io_read_ignores_writes() {
|
|
let mut ps = vec![
|
|
make_proc(1, 100, 9999),
|
|
make_proc(2, 500, 0),
|
|
make_proc(3, 50, 50),
|
|
];
|
|
SortMode::IoRead.sort(&mut ps);
|
|
assert_eq!(ps[0].pid, 2);
|
|
assert_eq!(ps[1].pid, 1);
|
|
assert_eq!(ps[2].pid, 3);
|
|
}
|
|
|
|
#[test]
|
|
fn sort_by_io_write_ignores_reads() {
|
|
let mut ps = vec![
|
|
make_proc(1, 9999, 100),
|
|
make_proc(2, 0, 500),
|
|
make_proc(3, 50, 50),
|
|
];
|
|
SortMode::IoWrite.sort(&mut ps);
|
|
assert_eq!(ps[0].pid, 2); // write 500
|
|
assert_eq!(ps[1].pid, 1); // write 100
|
|
assert_eq!(ps[2].pid, 3); // write 50
|
|
}
|
|
|
|
#[test]
|
|
fn sort_by_io_read_pushes_missing_to_bottom() {
|
|
let mut ps = vec![
|
|
make_proc_none(1),
|
|
make_proc(2, 200, 9999),
|
|
make_proc_none(3),
|
|
make_proc(4, 100, 0),
|
|
];
|
|
SortMode::IoRead.sort(&mut ps);
|
|
assert_eq!(ps[0].pid, 2);
|
|
assert_eq!(ps[1].pid, 4);
|
|
// None entries sort below; stable sort preserves input order.
|
|
assert_eq!(ps[2].pid, 1);
|
|
assert_eq!(ps[3].pid, 3);
|
|
}
|
|
|
|
#[test]
|
|
fn sort_by_io_write_pushes_missing_to_bottom() {
|
|
let mut ps = vec![
|
|
make_proc_none(1),
|
|
make_proc(2, 9999, 200),
|
|
make_proc_none(3),
|
|
make_proc(4, 0, 100),
|
|
];
|
|
SortMode::IoWrite.sort(&mut ps);
|
|
assert_eq!(ps[0].pid, 2);
|
|
assert_eq!(ps[1].pid, 4);
|
|
assert_eq!(ps[2].pid, 1);
|
|
assert_eq!(ps[3].pid, 3);
|
|
}
|
|
|
|
#[test]
|
|
fn compute_rate_kbs_basic_delta() {
|
|
// 1024 KiB over 2.0s = 512.0 KiB/s
|
|
let r = compute_rate_kbs(Some(1000), Some(2024), 2.0);
|
|
assert_eq!(r, Some(512.0));
|
|
}
|
|
|
|
#[test]
|
|
fn compute_rate_kbs_returns_none_when_prev_missing() {
|
|
assert_eq!(compute_rate_kbs(None, Some(1000), 1.0), None);
|
|
}
|
|
|
|
#[test]
|
|
fn compute_rate_kbs_returns_none_when_now_missing() {
|
|
assert_eq!(compute_rate_kbs(Some(1000), None, 1.0), None);
|
|
}
|
|
|
|
#[test]
|
|
fn compute_rate_kbs_returns_none_when_dt_zero() {
|
|
assert_eq!(compute_rate_kbs(Some(1000), Some(2000), 0.0), None);
|
|
assert_eq!(compute_rate_kbs(Some(1000), Some(2000), -1.0), None);
|
|
}
|
|
|
|
#[test]
|
|
fn compute_rate_kbs_saturates_on_underflow() {
|
|
// Now < Prev (clock reset) should saturate to 0, not wrap.
|
|
let r = compute_rate_kbs(Some(2000), Some(1000), 1.0);
|
|
assert_eq!(r, Some(0.0));
|
|
}
|
|
|
|
#[test]
|
|
fn compute_rate_kbs_first_sample_is_zero() {
|
|
// Sample N == Sample N+1 (process idle between samples).
|
|
let r = compute_rate_kbs(Some(5000), Some(5000), 1.0);
|
|
assert_eq!(r, Some(0.0));
|
|
}
|
|
|
|
#[test]
|
|
fn io_total_rate_kbs_sums_read_write() {
|
|
let mut p = make_proc(1, 100, 50);
|
|
p.io_read_rate_kbs = Some(200.0);
|
|
p.io_write_rate_kbs = Some(300.0);
|
|
assert_eq!(p.io_total_rate_kbs(), Some(500.0));
|
|
}
|
|
|
|
#[test]
|
|
fn io_total_rate_kbs_none_when_field_missing() {
|
|
let p = make_proc(1, 100, 50);
|
|
assert_eq!(p.io_total_rate_kbs(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn sort_by_io_rate_uses_total() {
|
|
let mut ps = vec![
|
|
ProcessInfo {
|
|
pid: 1,
|
|
io_read_rate_kbs: Some(100.0),
|
|
io_write_rate_kbs: Some(900.0),
|
|
..make_proc(1, 0, 0)
|
|
},
|
|
ProcessInfo {
|
|
pid: 2,
|
|
io_read_rate_kbs: Some(500.0),
|
|
io_write_rate_kbs: Some(500.0),
|
|
..make_proc(2, 0, 0)
|
|
},
|
|
];
|
|
SortMode::IoRate.sort(&mut ps);
|
|
// Both have total 1000; stable sort preserves input order.
|
|
assert_eq!(ps[0].pid, 1);
|
|
assert_eq!(ps[1].pid, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn sort_by_io_read_rate_pushes_missing_to_bottom() {
|
|
let mut ps = vec![
|
|
make_proc_none(1),
|
|
ProcessInfo {
|
|
pid: 2,
|
|
io_read_rate_kbs: Some(200.0),
|
|
..make_proc(2, 0, 0)
|
|
},
|
|
make_proc_none(3),
|
|
ProcessInfo {
|
|
pid: 4,
|
|
io_read_rate_kbs: Some(100.0),
|
|
..make_proc(4, 0, 0)
|
|
},
|
|
];
|
|
SortMode::IoReadRate.sort(&mut ps);
|
|
assert_eq!(ps[0].pid, 2);
|
|
assert_eq!(ps[1].pid, 4);
|
|
assert_eq!(ps[2].pid, 1);
|
|
assert_eq!(ps[3].pid, 3);
|
|
}
|
|
|
|
fn make_v(pid: u32, vsize_kb: u64, rss_kb: u64) -> ProcessInfo {
|
|
ProcessInfo {
|
|
pid,
|
|
vsize_kb,
|
|
rss_kb,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn sort_by_vsize_descending() {
|
|
let mut ps = vec![
|
|
make_v(1, 100_000, 1_000),
|
|
make_v(2, 500_000, 5_000),
|
|
make_v(3, 50_000, 500),
|
|
];
|
|
SortMode::VSize.sort(&mut ps);
|
|
assert_eq!(ps[0].pid, 2); // vsize 500_000
|
|
assert_eq!(ps[1].pid, 1); // vsize 100_000
|
|
assert_eq!(ps[2].pid, 3); // vsize 50_000
|
|
}
|
|
|
|
#[test]
|
|
fn sort_by_vsize_uses_vsize_not_rss() {
|
|
// The key property: SortMode::VSize sorts by VSZ, not RSS.
|
|
// Pid 1 has HUGE vsize but tiny rss; pid 2 has tiny vsize
|
|
// but HUGE rss. VSize sort puts 1 first; Rss sort would
|
|
// put 2 first.
|
|
let mut ps = vec![
|
|
make_v(1, 999_999_999, 1),
|
|
make_v(2, 1, 999_999_999),
|
|
];
|
|
SortMode::VSize.sort(&mut ps);
|
|
assert_eq!(ps[0].pid, 1);
|
|
assert_eq!(ps[1].pid, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn sort_ascending_flips_rss_order() {
|
|
// v1.38: the same sort key produces opposite order
|
|
// when ascending is true. Rss sort: pid 1 (huge) first
|
|
// desc, pid 2 (small) first asc.
|
|
let mut ps = vec![
|
|
ProcessInfo { pid: 1, rss_kb: 1000, ..Default::default() },
|
|
ProcessInfo { pid: 2, rss_kb: 100, ..Default::default() },
|
|
ProcessInfo { pid: 3, rss_kb: 500, ..Default::default() },
|
|
];
|
|
SortMode::Rss.sort_ascending(&mut ps, false);
|
|
assert_eq!(ps[0].pid, 1); // 1000 (desc)
|
|
assert_eq!(ps[1].pid, 3); // 500
|
|
assert_eq!(ps[2].pid, 2); // 100
|
|
SortMode::Rss.sort_ascending(&mut ps, true);
|
|
assert_eq!(ps[0].pid, 2); // 100 (asc)
|
|
assert_eq!(ps[1].pid, 3); // 500
|
|
assert_eq!(ps[2].pid, 1); // 1000
|
|
}
|
|
|
|
#[test]
|
|
fn sort_by_rchar_descending() {
|
|
// RChar sort uses io_rchar_kb (VFS-level reads).
|
|
let mut ps = vec![
|
|
ProcessInfo {
|
|
pid: 1,
|
|
io_rchar_kb: 100,
|
|
..Default::default()
|
|
},
|
|
ProcessInfo {
|
|
pid: 2,
|
|
io_rchar_kb: 5000,
|
|
..Default::default()
|
|
},
|
|
ProcessInfo {
|
|
pid: 3,
|
|
io_rchar_kb: 0,
|
|
..Default::default()
|
|
},
|
|
];
|
|
SortMode::RChar.sort(&mut ps);
|
|
assert_eq!(ps[0].pid, 2);
|
|
assert_eq!(ps[1].pid, 1);
|
|
assert_eq!(ps[2].pid, 3);
|
|
}
|
|
|
|
#[test]
|
|
fn sort_by_wchar_descending() {
|
|
let mut ps = vec![
|
|
ProcessInfo {
|
|
pid: 1,
|
|
io_wchar_kb: 999_999,
|
|
..Default::default()
|
|
},
|
|
ProcessInfo {
|
|
pid: 2,
|
|
io_wchar_kb: 1,
|
|
..Default::default()
|
|
},
|
|
];
|
|
SortMode::WChar.sort(&mut ps);
|
|
assert_eq!(ps[0].pid, 1);
|
|
assert_eq!(ps[1].pid, 2);
|
|
}
|
|
|
|
fn make_p(ppid: u32, pid: u32) -> ProcessInfo {
|
|
ProcessInfo {
|
|
pid,
|
|
ppid,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn sort_tree_emits_parents_before_children() {
|
|
// Tree:
|
|
// 1
|
|
// ├── 2
|
|
// │ └── 3
|
|
// └── 4
|
|
let mut ps = vec![
|
|
make_p(0, 1), // root
|
|
make_p(1, 2), // child of 1
|
|
make_p(2, 3), // child of 2
|
|
make_p(1, 4), // child of 1
|
|
];
|
|
sort_tree(&mut ps, SortMode::Pid);
|
|
let pids: Vec<u32> = ps.iter().map(|p| p.pid).collect();
|
|
// 1 must come before 2 and 4; 2 must come before 3.
|
|
let pos1 = pids.iter().position(|&p| p == 1).unwrap();
|
|
let pos2 = pids.iter().position(|&p| p == 2).unwrap();
|
|
let pos3 = pids.iter().position(|&p| p == 3).unwrap();
|
|
let pos4 = pids.iter().position(|&p| p == 4).unwrap();
|
|
assert!(pos1 < pos2);
|
|
assert!(pos1 < pos4);
|
|
assert!(pos2 < pos3);
|
|
}
|
|
|
|
#[test]
|
|
fn sort_tree_handles_orphans() {
|
|
// 1 (root), 2 (orphan: ppid=999 not in list), 3 (child of 1)
|
|
let mut ps = vec![
|
|
make_p(0, 1),
|
|
make_p(999, 2),
|
|
make_p(1, 3),
|
|
];
|
|
sort_tree(&mut ps, SortMode::Pid);
|
|
let pids: Vec<u32> = ps.iter().map(|p| p.pid).collect();
|
|
// All 3 present.
|
|
assert_eq!(pids.len(), 3);
|
|
assert!(pids.contains(&1));
|
|
assert!(pids.contains(&2));
|
|
assert!(pids.contains(&3));
|
|
// 1 still before 3.
|
|
let pos1 = pids.iter().position(|&p| p == 1).unwrap();
|
|
let pos3 = pids.iter().position(|&p| p == 3).unwrap();
|
|
assert!(pos1 < pos3);
|
|
}
|
|
|
|
#[test]
|
|
fn sort_tree_handles_cycles() {
|
|
// 1 (ppid=2), 2 (ppid=1) — cycle. Both treated as roots.
|
|
let mut ps = vec![
|
|
make_p(2, 1),
|
|
make_p(1, 2),
|
|
];
|
|
sort_tree(&mut ps, SortMode::Pid);
|
|
let pids: Vec<u32> = ps.iter().map(|p| p.pid).collect();
|
|
// Both present; no infinite loop.
|
|
assert_eq!(pids.len(), 2);
|
|
assert!(pids.contains(&1));
|
|
assert!(pids.contains(&2));
|
|
}
|
|
|
|
#[test]
|
|
fn sort_tree_empty_input() {
|
|
let mut ps: Vec<ProcessInfo> = Vec::new();
|
|
sort_tree(&mut ps, SortMode::Pid);
|
|
assert!(ps.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn apply_fold_empty_set_is_identity() {
|
|
let input = vec![make_p(0, 1), make_p(1, 2)];
|
|
let folded = std::collections::BTreeSet::new();
|
|
let out = apply_fold(input.clone(), &folded);
|
|
assert_eq!(out.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn apply_fold_hides_descendants_of_folded_root() {
|
|
// Tree: 1 -> 2 -> 3 -> 4 (already in tree order from sort_tree)
|
|
let input = vec![
|
|
make_p(0, 1),
|
|
make_p(1, 2),
|
|
make_p(2, 3),
|
|
make_p(3, 4),
|
|
];
|
|
let mut folded = std::collections::BTreeSet::new();
|
|
folded.insert(1); // fold root
|
|
let out = apply_fold(input, &folded);
|
|
let pids: Vec<u32> = out.iter().map(|p| p.pid).collect();
|
|
// 1 visible (it's the fold target itself), 2/3/4 hidden.
|
|
assert_eq!(pids, vec![1]);
|
|
}
|
|
|
|
#[test]
|
|
fn apply_fold_hides_subtree_of_folded_child() {
|
|
// Tree: 1 -> 2 -> 3 (and 1 -> 4, sibling of 2)
|
|
let input = vec![
|
|
make_p(0, 1),
|
|
make_p(1, 2),
|
|
make_p(2, 3),
|
|
make_p(1, 4),
|
|
];
|
|
let mut folded = std::collections::BTreeSet::new();
|
|
folded.insert(2); // fold middle node
|
|
let out = apply_fold(input, &folded);
|
|
let pids: Vec<u32> = out.iter().map(|p| p.pid).collect();
|
|
// 1 visible, 2 visible (fold target), 3 hidden, 4 visible
|
|
// (sibling of 2, not in 2's subtree).
|
|
assert_eq!(pids, vec![1, 2, 4]);
|
|
}
|
|
|
|
#[test]
|
|
fn apply_fold_unfold_restores() {
|
|
let input = vec![make_p(0, 1), make_p(1, 2)];
|
|
let mut folded = std::collections::BTreeSet::new();
|
|
folded.insert(1);
|
|
let once = apply_fold(input.clone(), &folded);
|
|
assert_eq!(once.len(), 1);
|
|
folded.remove(&1);
|
|
let twice = apply_fold(input, &folded);
|
|
assert_eq!(twice.len(), 2);
|
|
}
|
|
}
|