redbear-power: v1.33 SortMode::RChar / WChar (VFS-level IO)

Adds two new sort modes that target the VFS-level byte counts
exposed by /proc/[pid]/io:rchar and wchar. These differ from
the existing read_bytes/write_bytes:

  read_bytes: storage-level bytes that hit the disk
  rchar:      VFS-level bytes (includes cache hits, tty output)

Useful for the 'is this proc doing lots of syscalls?' question
that read_bytes misses (cache-served reads count toward rchar
but not read_bytes).

- New fields on ProcessInfo: io_rchar_kb: u64, io_wchar_kb: u64
  (u64, not Option: rchar/wchar default to 0 if absent on
  very old kernels, never sentinel-needed)
- read_io_file() now returns a 4-tuple
  (read_bytes, write_bytes, rchar, wchar) instead of 2-tuple
- New SortMode variants RChar and WChar
  - cycle: Rss -> Cpu -> Io -> ... -> IoWriteRate -> RChar
    -> WChar -> VSize -> Pid -> Name -> Rss
  - name(): 'RChr' and 'WChr'
  - sort(): descending by io_rchar_kb / io_wchar_kb
- Column-swap: when sort is RChar, MEM column shows RChr value;
  when WChar, shows WChr. Default and other modes use RSS.

Test count 123 -> 125 (+2):
- sort_by_rchar_descending (VFS reads, pid 2 with 5000 first)
- sort_by_wchar_descending (VFS writes, pid 1 with 999_999 first)
- sort_cycle and io_name_is_io updated for RChar/WChar

Redox stripped binary: 4,225,896 bytes (+12 KiB from v1.32).
Compile warnings: 55 (unchanged).
This commit is contained in:
2026-06-21 07:29:56 +03:00
parent bdec5061ef
commit c1044da3b7
2 changed files with 109 additions and 20 deletions
@@ -30,6 +30,8 @@ pub enum SortMode {
IoRate,
IoReadRate,
IoWriteRate,
RChar,
WChar,
VSize,
Pid,
Name,
@@ -45,7 +47,9 @@ impl SortMode {
SortMode::IoWrite => SortMode::IoRate,
SortMode::IoRate => SortMode::IoReadRate,
SortMode::IoReadRate => SortMode::IoWriteRate,
SortMode::IoWriteRate => SortMode::VSize,
SortMode::IoWriteRate => SortMode::RChar,
SortMode::RChar => SortMode::WChar,
SortMode::WChar => SortMode::VSize,
SortMode::VSize => SortMode::Pid,
SortMode::Pid => SortMode::Name,
SortMode::Name => SortMode::Rss,
@@ -61,6 +65,8 @@ impl SortMode {
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",
@@ -85,6 +91,8 @@ impl SortMode {
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)),
@@ -283,6 +291,14 @@ 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>,
/// 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
@@ -361,22 +377,34 @@ fn read_comm(pid: u32) -> String {
.unwrap_or_else(|| "?".to_string())
}
/// Parse `/proc/[pid]/io` content and return `(read_bytes, write_bytes)`
/// in bytes (not KiB). Returns `None` if the file cannot be read.
/// The conversion to KiB happens in the caller so a single sentinel
/// value (`None`) propagates through the field types.
fn read_io_file(pid: u32) -> Option<(u64, u64)> {
/// 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?))
Some((read?, write?, rchar, wchar))
}
/// Compute KiB/s rate from a prev/current sample pair. Returns `None`
@@ -415,9 +443,9 @@ fn parse_stat_line(line: &str) -> Option<ProcessInfo> {
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) = match read_io_file(pid) {
Some((r, w)) => (Some(r / 1024), Some(w / 1024)),
None => (None, None),
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,
@@ -434,6 +462,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_rchar_kb: rchar_bytes,
io_wchar_kb: wchar_bytes,
io_read_rate_kbs: None,
io_write_rate_kbs: None,
})
@@ -663,7 +693,9 @@ mod sort_unit_tests {
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::VSize);
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);
@@ -836,7 +868,9 @@ mod io_sort_unit_tests {
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::VSize);
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);
@@ -850,6 +884,8 @@ mod io_sort_unit_tests {
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");
}
@@ -1043,6 +1079,51 @@ mod io_sort_unit_tests {
assert_eq!(ps[1].pid, 2);
}
#[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,
@@ -923,10 +923,11 @@ pub fn render_process_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> {
// address space) so the column being sorted IS the column
// being shown. No new column is added; this keeps the panel
// width bounded.
let mem_header = if app.process_sort == crate::process::SortMode::VSize {
"VSZ"
} else {
"RSS"
let mem_header = match app.process_sort {
crate::process::SortMode::VSize => "VSZ",
crate::process::SortMode::RChar => "RChr",
crate::process::SortMode::WChar => "WChr",
_ => "RSS",
};
let header_str = format!(
" PID STATE PRIO NI THR CPU% IO RATE {:<11} IO-RATE CPU% RSS COMM",
@@ -956,10 +957,17 @@ pub fn render_process_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> {
Some(kbs) => crate::process::ProcessInfo::format_rate_kbs(kbs),
None => "".to_string(),
};
let mem_str = if app.process_sort == crate::process::SortMode::VSize {
crate::process::ProcessInfo::format_memory_kb(p.vsize_kb)
} else {
crate::process::ProcessInfo::format_memory_kb(p.rss_kb)
let mem_str = match app.process_sort {
crate::process::SortMode::VSize => {
crate::process::ProcessInfo::format_memory_kb(p.vsize_kb)
}
crate::process::SortMode::RChar => {
crate::process::ProcessInfo::format_memory_kb(p.io_rchar_kb)
}
crate::process::SortMode::WChar => {
crate::process::ProcessInfo::format_memory_kb(p.io_wchar_kb)
}
_ => crate::process::ProcessInfo::format_memory_kb(p.rss_kb),
};
// Three per-PID sparklines: IO-RATE (12 samples),
// CPU% (6 samples), RSS (6 samples). The smaller