redbear-power: v1.25 IO rate column + rate sort modes

Per-process IO is now also a throughput metric (KiB/s), not just
cumulative. Cumulative bytes favor long-lived processes regardless
of activity; rate is what operators actually want for 'what is
hammering the disk right now'.

- New fields on ProcessInfo: io_read_rate_kbs, io_write_rate_kbs
  (Option<f64>; None when prev missing, current None, or dt<=0)
- New method: io_total_rate_kbs() (sum; same None semantics)
- New helper: compute_rate_kbs(prev, now, dt) -> Option<f64>
  uses saturating_sub for clock-reset safety
- read_with_cpu_pct_sorted now also computes the two rate fields
  (negligible cost: 2 subs + 2 divs per process per refresh)
- New SortMode variants: IoRate, IoReadRate, IoWriteRate
  inserted in cycle after IoWrite
- name() returns 'IO/s', 'R/s', 'W/s' for status line
- New sort_by_io_rate_field() helper (Option<f64> partial_cmp)
- New format_rate_kbs() on ProcessInfo (KiB/s, MiB/s, GiB/s, TiB/s;
  saturates negative to 0)
- New RATE column in the Process panel between IO and RSS

Test count 87 -> 101 (+14):
- 6 compute_rate_kbs edge cases (basic, None prev/now, dt<=0,
  saturating underflow, idle = zero)
- 2 io_total_rate_kbs (sum, None)
- 2 sort-by-rate (total, read-pushes-missing)
- 4 format_rate_kbs (sub-KiB, 1 MiB, 1 GiB, negative)
- sort_cycle and io_name_is_io updated for new variants

Redox stripped binary: 4,168,552 bytes (+49 KiB from v1.24;
14 new tests + 2 sort modes + 2 fields + render column + 3 helpers).
Compile warnings: 55 (unchanged).

Docs: local/docs/redbear-power-improvement-plan.md \xC2\xA749
This commit is contained in:
2026-06-21 00:50:30 +03:00
parent c8e19ea47f
commit aaa1b950b4
3 changed files with 372 additions and 5 deletions
@@ -4425,6 +4425,163 @@ The status line (`sort: IO-R`) is sufficient disambiguation.
---
## 49. v1.25 IO Rate Column + Rate Sort (2026-06-21)
Per the v1.22 audit (I5: "consider adding kbps or bytes/sec IO
throughput column rather than cumulative IO"), v1.25 promotes
per-process IO from a cumulative-only metric to also showing
throughput in KiB/s. Cumulative bytes favor long-lived processes
regardless of activity — a process that did 100 GB of IO three days
ago and is now idle will outrank an actively-thrashing one that
started 10 minutes ago. Rate is what operators actually want.
### 49.1 What was implemented
**Two new fields on `ProcessInfo`**:
- `io_read_rate_kbs: Option<f64>` — read KiB/s (delta of `io_read_kb`
across two reads divided by `dt_secs`).
- `io_write_rate_kbs: Option<f64>` — write KiB/s (delta of
`io_write_kb` across two reads divided by `dt_secs`).
`None` when the prev sample is missing (first read after startup) or
when either `io_read_kb`/`io_write_kb` is `None` for prev or current.
**`io_total_rate_kbs()` method** — sums read+write rates for
`SortMode::IoRate`. Same sentinel semantics as `io_total_kb()`:
returns `None` if either field is `None`.
**`compute_rate_kbs()` helper** — private fn that does the rate math:
```rust
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)
}
```
`saturating_sub` handles the (impossible in practice) clock-reset
case where a future sample is smaller than a past one. The `?`
operator propagates `None` from either prev or current.
**`read_with_cpu_pct_sorted` extension** — now also computes the
two rate fields after computing `cpu_pct`. The same `prev_p` lookup
serves both CPU% and rate calculations. Cost: 2 saturating subs + 2
f64 divs per process. Negligible vs. the file reads.
**Three new `SortMode` variants**:
- `SortMode::IoRate` — by total read+write rate
- `SortMode::IoReadRate` — by read rate only
- `SortMode::IoWriteRate` — by write rate only
Cycle updated to insert them between `IoWrite` and `Pid`:
`Rss → Cpu → Io → IoRead → IoWrite → IoRate → IoReadRate → IoWriteRate → Pid → Name`.
`name()` returns `"IO/s"`, `"R/s"`, `"W/s"` for status-line
disambiguation (the 3-char IO/s keeps the status line tight).
**New `sort_by_io_rate_field()` helper** — symmetric with the
existing `sort_by_io_field()` for `Option<u64>` cumulative sorts.
Uses `partial_cmp` for `Option<f64>` (NaN-safe); `unwrap_or(Equal)`
falls back to the same-ordering rule if both values are NaN.
**New `format_rate_kbs()` helper** on `ProcessInfo` — symmetric
with `format_memory_kb()`. 1024-base binary units (KiB/s, MiB/s,
GiB/s, TiB/s). `kbs.max(0.0)` saturates negative inputs to 0 (a
"negative rate" is meaningless and indicates a test fixture or
clock-reset edge case).
**Render-side new column** — the Process panel now has 10 columns:
`PID STATE PRIO NI THR CPU% IO RATE RSS COMM`. The RATE
column renders the total rate (read+write) via
`ProcessInfo::format_rate_kbs`. Renders em-dash when the rate is
`None` (first sample, unreadable IO, or prev sample missing).
### 49.2 Test coverage
Test count: **101** (up from 87 in v1.24).
New tests (14):
- `compute_rate_kbs_basic_delta` — 1024 KiB / 2.0s = 512.0 KiB/s
- `compute_rate_kbs_returns_none_when_prev_missing`
- `compute_rate_kbs_returns_none_when_now_missing`
- `compute_rate_kbs_returns_none_when_dt_zero` (both 0.0 and -1.0)
- `compute_rate_kbs_saturates_on_underflow` (now < prev → 0.0)
- `compute_rate_kbs_first_sample_is_zero` (process idle)
- `io_total_rate_kbs_sums_read_write` (200 + 300 = 500.0)
- `io_total_rate_kbs_none_when_field_missing`
- `sort_by_io_rate_uses_total` (tie → stable input order)
- `sort_by_io_read_rate_pushes_missing_to_bottom`
- `format_rate_below_1kibs` (500.0 → "500.0 KiB/s")
- `format_rate_1mibs` (1024.0 → "1.0 MiB/s")
- `format_rate_1gibs` (1024² → "1.0 GiB/s")
- `format_rate_saturates_negative_to_zero`
Updated tests (2):
- `sort_cycle` and `sort_cycle_includes_io` — extended for the
3 new rate variants in the cycle.
- `io_name_is_io` — also locks "IO/s", "R/s", "W/s" strings.
### 49.3 Cross-compile + smoke test results
| Target | Size | SHA256 |
|--------------|-------------|-------------------------------------------------------------------|
| Linux host | 3.0 MB | (run from `target/release/redbear-power`) |
| Redox x86_64 | 4,168,552 B | `b103a0e456d308ba1e518edbf942eff17f251bfd123216287a67efaa6614aa16` |
Binary size delta: +49,152 bytes (≈48 KiB) from v1.24. The growth
comes from 14 new tests + 2 new sort modes + 2 new fields + the
render column + `format_rate_kbs` + `compute_rate_kbs` +
`io_total_rate_kbs` + `sort_by_io_rate_field` helper.
Smoke test confirms the RATE column header renders:
```
PID STATE PRIO NI THR CPU% IO RATE RSS COMM
```
The `--once` mode uses `read()` not `read_with_cpu_pct_sorted()` so
all rate values are `None` for the first sample. The interactive TUI
populates them on the second refresh (typically 500 ms after start).
### 49.4 Compute cost
The new `compute_rate_kbs` adds 2 saturating subs + 2 f64 divs per
process per refresh. On a system with 600 processes and a 13-tick
(6.5 s) refresh rate, that's 600 × 4 = 2400 arithmetic ops per
6.5 s = ~370 ops/sec. Completely negligible vs. the 600 file opens
(20 syscalls each = 12,000 syscalls per 6.5 s) for the procfs reads
that we already do.
### 49.5 Why a RATE column instead of replacing IO
The IO column (cumulative) and the RATE column (throughput) answer
different questions:
| Question | Column |
|----------|--------|
| What process has done the most disk IO over its lifetime? | IO |
| What process is hammering the disk RIGHT NOW? | RATE |
| Has this process's IO gone up since last check? | RATE delta |
| Will this process's log rotate soon? | IO |
Both are useful. Removing IO would lose the cumulative view; not
adding RATE would leave operators with "is the process thrashing?"
as an unanswerable question.
### 49.6 What was NOT changed (intentional)
- **Per-thread IO** (htop scans `task/[pid]/io`) — not a common
operator question on a power TUI, and adds N×file-open cost.
- **RCHAR/WCHAR/SYSCR/SYSCW** (htop's "IO details" columns) —
beyond the power/thermal scope. Defer to a future v1.26 if user
demand appears.
- **Persistent rate sparkline** (rolling average of last N samples) —
a per-process IO rate over time is a natural visualization but
requires storing a Vec<Sample> per process across refreshes. Defer
to a future v1.27 with proper memory accounting.
---
## See Also
- **`local/docs/RATATUI-APP-PATTERNS.md`** §13 — the canonical ratatui 0.30 best-practices update that this plan is derived from. Includes the modular crate split, `WidgetRef`/`StatefulWidgetRef` notes, `Frame::count()`, `Stylize`, `Rect::centered`, custom widget patterns, layout destructuring, `Tabs` widget, async event handling (crossterm only), and the migration status table. Use this as the implementation guide while this doc is the roadmap.
@@ -28,6 +28,9 @@ pub enum SortMode {
Io,
IoRead,
IoWrite,
IoRate,
IoReadRate,
IoWriteRate,
Pid,
Name,
}
@@ -39,7 +42,10 @@ impl SortMode {
SortMode::Cpu => SortMode::Io,
SortMode::Io => SortMode::IoRead,
SortMode::IoRead => SortMode::IoWrite,
SortMode::IoWrite => SortMode::Pid,
SortMode::IoWrite => SortMode::IoRate,
SortMode::IoRate => SortMode::IoReadRate,
SortMode::IoReadRate => SortMode::IoWriteRate,
SortMode::IoWriteRate => SortMode::Pid,
SortMode::Pid => SortMode::Name,
SortMode::Name => SortMode::Rss,
}
@@ -51,6 +57,9 @@ impl SortMode {
SortMode::Io => "IO",
SortMode::IoRead => "IO-R",
SortMode::IoWrite => "IO-W",
SortMode::IoRate => "IO/s",
SortMode::IoReadRate => "R/s",
SortMode::IoWriteRate => "W/s",
SortMode::Pid => "PID",
SortMode::Name => "Name",
}
@@ -71,6 +80,9 @@ impl SortMode {
}),
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::Pid => processes.sort_by_key(|p| p.pid),
SortMode::Name => processes.sort_by(|a, b| a.comm.cmp(&b.comm)),
}
@@ -93,6 +105,22 @@ where
});
}
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,
}
});
}
#[derive(Default, Clone, Debug)]
pub struct ProcessInfo {
pub pid: u32,
@@ -122,6 +150,15 @@ pub struct ProcessInfo {
/// Cumulative write bytes (KiB) from
/// `/proc/[pid]/io:write_bytes`. Same caveats as `io_read_kb`.
pub io_write_kb: Option<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 {
@@ -140,6 +177,15 @@ impl ProcessInfo {
}
}
/// 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;
@@ -150,6 +196,22 @@ impl ProcessInfo {
}
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)]
@@ -184,6 +246,20 @@ fn read_io_file(pid: u32) -> Option<(u64, u64)> {
Some((read?, write?))
}
/// 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(')')?;
@@ -225,6 +301,8 @@ fn parse_stat_line(line: &str) -> Option<ProcessInfo> {
cpu_pct: 0.0,
io_read_kb: io_read_bytes,
io_write_kb: io_write_bytes,
io_read_rate_kbs: None,
io_write_rate_kbs: None,
})
}
@@ -294,6 +372,8 @@ impl ProcInfo {
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
@@ -326,6 +406,26 @@ mod tests {
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 ...
@@ -432,7 +532,10 @@ mod sort_unit_tests {
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::Pid);
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::Pid);
assert_eq!(SortMode::Pid.next(), SortMode::Name);
assert_eq!(SortMode::Name.next(), SortMode::Rss);
}
@@ -601,7 +704,10 @@ mod io_sort_unit_tests {
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::Pid);
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::Pid);
assert_eq!(SortMode::Pid.next(), SortMode::Name);
assert_eq!(SortMode::Name.next(), SortMode::Rss);
}
@@ -611,6 +717,9 @@ mod io_sort_unit_tests {
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");
}
#[test]
@@ -669,4 +778,100 @@ mod io_sort_unit_tests {
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);
}
}
@@ -877,7 +877,7 @@ pub fn render_process_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> {
).set_style(theme::LABEL_BOLD)));
lines.push(Line::from(""));
lines.push(Line::from(vec![
" PID STATE PRIO NI THR CPU% IO RSS COMM".set_style(theme::LABEL),
" PID STATE PRIO NI THR CPU% IO RATE RSS COMM".set_style(theme::LABEL),
]));
for p in &proc.processes {
if !app.process_filter.is_empty()
@@ -890,8 +890,12 @@ lines.push(Line::from(vec![
Some(kb) => crate::process::ProcessInfo::format_memory_kb(kb),
None => "".to_string(),
};
let rate_str = match p.io_total_rate_kbs() {
Some(kbs) => crate::process::ProcessInfo::format_rate_kbs(kbs),
None => "".to_string(),
};
lines.push(Line::from(format!(
" {:<7} {} {:<4} {:<3} {:<3} {:<6} {:<11} {:<11} {}",
" {:<7} {} {:<4} {:<3} {:<3} {:<6} {:<11} {:<11} {:<11} {}",
p.pid,
p.state,
p.priority,
@@ -899,6 +903,7 @@ lines.push(Line::from(vec![
p.num_threads,
format!("{:.1}", p.cpu_pct),
io_str,
rate_str,
crate::process::ProcessInfo::format_memory_kb(p.rss_kb),
comm_truncated,
).set_style(theme::VALUE)));