redbear-power: v1.42 CPU affinity

The next item from the v1.41 deferred list: read
/proc/<pid>/status:Cpus_allowed_list and display it as
both a single-char row indicator and a full expanded
list in the PID detail popup. htop parity.

Kernel format
  The kernel emits the list as comma-separated ranges:
    "0-3,5,7-11" means CPUs 0, 1, 2, 3, 5, 7, 8, 9,
    10, 11
  Cpus_allowed_list is the HARD affinity mask (settable
  via sched_setaffinity(2)). v1.42 reads it because it
  matches what an operator sees with 'taskset'.

New functions
  - read_cpu_affinity(pid): parses the kernel string
  - parse_cpu_list(s): public, testable parser
  - format_cpu_list(ids): inverse of parse_cpu_list
  - read_cpu_affinity_for_pid(pid): pub wrapper for the
    PID detail popup

Two display modes
  - Process panel row: '*' (subset), ' ' (all CPUs),
    '?' (unknown). Single char so COMM stays visible.
  - PID detail popup: full range string + expanded
    Vec (truncated to 8 items on large machines).

New field on ProcessInfo
  - cpu_affinity: Option<Vec<u32>>

Robustness
  - Whitespace tolerated
  - Out-of-order or duplicate IDs deduped and sorted
  - Non-numeric chunks silently dropped
  - Reversed ranges (start > end) silently dropped
  - Empty input returns empty Vec (popup distinguishes
    'no data' / None vs 'explicitly empty' / Some(empty))

Tests
  - 13 new tests (11 in process.rs for parse/format/
    read, 1 self-affinity test, 1 missing-pid test).
  - 183/183 tests pass (was 170 in v1.41).

The improvement plan doc is also updated with §66
covering the v1.42 architecture, kernel format, the
two display modes, the parse/format inverse pair, and
the v1.43 deferred list.
This commit is contained in:
2026-06-21 13:38:24 +03:00
parent 57a3ea6c9e
commit 0771fa2ff6
3 changed files with 448 additions and 2 deletions
@@ -5697,6 +5697,99 @@ dir within a single refresh cycle.
per-thread, so this would be a synthetic derivation.
Defer to v1.42 if user demand appears.
## 66. v1.42 CPU Affinity (2026-06-21)
The next item from the v1.41 deferred list: CPU affinity
from `/proc/<pid>/status:Cpus_allowed_list`. htop has
this as a column; v1.42 ships it as both a single-char
row indicator (Process panel) and a full expanded list
(PID detail popup).
### 66.1 Kernel format
The kernel emits the list as comma-separated ranges:
```
0-3,5,7-11 means CPUs 0, 1, 2, 3, 5, 7, 8, 9, 10, 11
```
`Cpus_allowed_list` is the **hard** affinity mask
(settable via `sched_setaffinity(2)`). The kernel also
exposes `Cpus_allowed` (same format, but only the
effective subset — without isolated CPUs that exist
but aren't allowed for this process). v1.42 reads
`Cpus_allowed_list` because it matches what an operator
sees when they set the affinity with `taskset`.
### 66.2 Two display modes
| Location | Format | Why |
|----------|--------|-----|
| Process panel row | `*` (subset) / ` ` (all CPUs) / `?` (unknown) | Single char so it doesn't push COMM off the visible area. |
| PID detail popup | Full range string + expanded list | Operators debugging thread pinning need the exact list. |
The `*` indicator fires when the affinity list is
**shorter** than the host's CPU count. We can't compare
specific IDs (host CPUs may have non-contiguous IDs on
NUMA systems) so the comparison is count-based. On
machines with hot-pluggable CPUs, the host CPU count
changes over time, and the indicator might briefly show
`*` for a process with the full mask. The popup shows
the truth.
### 66.3 `parse_cpu_list` and `format_cpu_list`
Inverse pair, both `pub` for testability:
```rust
parse_cpu_list("0-3,5,7-11") == [0, 1, 2, 3, 5, 7, 8, 9, 10, 11]
format_cpu_list(&[0,1,2,3,5,7,8,9,10,11]) == "0-3,5,7-11"
```
Robustness:
- Whitespace tolerated (`" 0-3 , 5 "` parses correctly)
- Out-of-order or duplicate IDs are deduped and sorted
- Non-numeric chunks silently dropped (kernel never
emits these, but a corrupt procfs might)
- A range with start > end silently dropped
- Empty input returns empty Vec (popup distinguishes
"no data" / None vs "explicitly empty" / Some(empty))
### 66.4 Tests
| Test | What it verifies |
|------|------------------|
| `parse_cpu_list_basic` | Single range expands correctly. |
| `parse_cpu_list_mixed_ranges_and_singletons` | The canonical mixed format. |
| `parse_cpu_list_handles_whitespace` | Whitespace tolerated. |
| `parse_cpu_list_dedupes_and_sorts` | Out-of-order and duplicate IDs are deduped. |
| `parse_cpu_list_silently_drops_malformed_chunks` | Non-numeric and reversed ranges dropped. |
| `parse_cpu_list_empty_returns_empty` | Empty input returns empty Vec. |
| `format_cpu_list_basic` | Contiguous range collapses to "start-end". |
| `format_cpu_list_mixed` | Mixed ranges and singletons. |
| `format_cpu_list_empty` | Empty input returns "". |
| `format_cpu_list_single_id` | Single CPU ID renders without range dash. |
| `parse_and_format_round_trip` | parse → format produces the original kernel string (4 cases). |
| `read_cpu_affinity_handles_self` | Test runner's own affinity is non-empty + sorted. |
| `read_cpu_affinity_returns_none_for_missing_pid` | None for non-existent PID. |
**183/183 tests pass as of v1.42.**
### 66.5 What was NOT changed (intentional)
- **History reclaim LRU** — defer to v1.43. Even at
thousands of short-lived procs, each `VecDeque<u8>` is
~24 bytes; the LRU cap is a "polish" feature, not a
"prevents OOM" feature.
- **Per-thread CPU%** (synthetic) — defer to v1.43 if
user demand appears. The Linux kernel only exposes
process-total CPU%, not per-thread.
- **CPU affinity setter (taskset-style keypress)** —
defer to v1.43. The reader side is in v1.42; the
writer side requires an ioctl wrapper that we don't
have yet.
## 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.
@@ -450,6 +450,17 @@ pub struct ProcessInfo {
/// delta of `thread_io_write_kb`. Same sentinel semantics
/// as `thread_io_read_rate_kbs`.
pub thread_io_write_rate_kbs: Option<f64>,
/// CPU affinity parsed from
/// `/proc/<pid>/status:Cpus_allowed_list`. v1.42: htop
/// parity. Stored as a list of individual CPU IDs in
/// ascending order (e.g. `[0, 1, 2, 3, 5, 7, 8, 9, 10, 11]`
/// for the kernel string `"0-3,5,7-11"`). `None` when
/// the field is missing (older kernels, Redox, or
/// permission denied on the status file). The full
/// expanded list is shown in the PID detail popup; the
/// Process panel renders a compact range-string form
/// (e.g. "0-3,5,7-11") for at-a-glance scanning.
pub cpu_affinity: Option<Vec<u32>>,
}
impl ProcessInfo {
@@ -574,6 +585,14 @@ pub fn read_thread_io_for_pid(pid: u32) -> (Option<u64>, Option<u64>) {
read_thread_io(pid)
}
/// Public wrapper for `read_cpu_affinity` used by the PID
/// detail popup. v1.42. Returns the same `Option<Vec<u32>>`
/// shape, so the popup can distinguish "no data" (None)
/// from "explicitly empty" (Some(empty)).
pub fn read_cpu_affinity_for_pid(pid: u32) -> Option<Vec<u32>> {
read_cpu_affinity(pid)
}
/// Read per-thread IO aggregated across all threads of `pid`.
/// Walks `/proc/<pid>/task/*/io` and sums `read_bytes` and
/// `write_bytes` across TIDs. Returns `(read_kb, write_kb)` as
@@ -634,6 +653,113 @@ fn read_thread_io(pid: u32) -> (Option<u64>, Option<u64>) {
}
}
/// Read CPU affinity from
/// `/proc/<pid>/status:Cpus_allowed_list`. The kernel
/// formats the list as comma-separated ranges:
/// `"0-3,5,7-11"` means CPUs 0, 1, 2, 3, 5, 7, 8, 9, 10, 11.
/// v1.42: htop parity. Returns `None` if the status file
/// is missing/unreadable OR if the field is absent (older
/// kernels, Redox). Returns `Some(empty Vec)` only if the
/// field is present but empty (which would mean the process
/// is pinned to NO CPUs — a misconfiguration we'd want to
/// surface in the popup). In practice the kernel always
/// emits at least one CPU ID, so `Some(empty Vec)` should
/// never occur in production.
fn read_cpu_affinity(pid: u32) -> Option<Vec<u32>> {
let content = fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
for line in content.lines() {
if let Some(rest) = line.strip_prefix("Cpus_allowed_list:") {
return Some(parse_cpu_list(rest.trim()));
}
}
None
}
/// Parse a kernel-format CPU list string
/// (e.g. `"0-3,5,7-11"`) into an ascending Vec of CPU
/// IDs. Each comma-separated chunk is either a single
/// integer (`"5"`) or a range (`"0-3"`). Single-CPU
/// ranges like `"5-5"` are folded to a single entry.
/// Returns an empty Vec for an empty input. v1.42.
///
/// Robustness:
/// - Whitespace in the input is tolerated (`" 0-3 , 5 "`
/// parses correctly).
/// - Out-of-order or duplicate IDs are de-duplicated and
/// sorted (the kernel always emits sorted, but a corrupt
/// procfs might not).
/// - Non-numeric chunks are silently dropped (the kernel
/// never emits these, but a `saturating_add` is not
/// applicable here because we're parsing strings, not
/// adding numbers).
/// - A range with start > end is silently dropped (would
/// be a kernel bug; we don't crash on malformed input).
pub fn parse_cpu_list(s: &str) -> Vec<u32> {
let mut out: Vec<u32> = Vec::new();
for chunk in s.split(',') {
let chunk = chunk.trim();
if chunk.is_empty() {
continue;
}
if let Some((lo, hi)) = chunk.split_once('-') {
let lo = match lo.trim().parse::<u32>() {
Ok(n) => n,
Err(_) => continue,
};
let hi = match hi.trim().parse::<u32>() {
Ok(n) => n,
Err(_) => continue,
};
if lo > hi {
continue;
}
for c in lo..=hi {
if !out.contains(&c) {
out.push(c);
}
}
} else if let Ok(n) = chunk.parse::<u32>() {
if !out.contains(&n) {
out.push(n);
}
}
}
out.sort_unstable();
out
}
/// Render a sorted Vec of CPU IDs back to the kernel's
/// compact range format. v1.42. Inverse of `parse_cpu_list`:
/// `format_cpu_list(&[0,1,2,3,5,7,8,9,10,11])` returns
/// `"0-3,5,7-11"`. Used by the Process panel's compact
/// affinity column. Empty input returns `""`.
pub fn format_cpu_list(ids: &[u32]) -> String {
if ids.is_empty() {
return String::new();
}
let mut out = String::new();
let mut i = 0;
while i < ids.len() {
let start = ids[i];
let mut end = start;
let mut j = i;
while j + 1 < ids.len() && ids[j + 1] == end + 1 {
j += 1;
end = ids[j];
}
if start == end {
out.push_str(&start.to_string());
} else {
out.push_str(&format!("{}-{}", start, end));
}
if j + 1 < ids.len() {
out.push(',');
}
i = j + 1;
}
out
}
/// 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
@@ -680,6 +806,10 @@ fn parse_stat_line(line: &str) -> Option<ProcessInfo> {
// attribution quirk that makes the two sometimes
// diverge).
let (thread_io_read_kb, thread_io_write_kb) = read_thread_io(pid);
// v1.42: CPU affinity from
// /proc/<pid>/status:Cpus_allowed_list. Optional
// (older kernels and Redox don't have it).
let cpu_affinity = read_cpu_affinity(pid);
Some(ProcessInfo {
pid,
comm,
@@ -703,6 +833,7 @@ fn parse_stat_line(line: &str) -> Option<ProcessInfo> {
thread_io_write_kb,
thread_io_read_rate_kbs: None,
thread_io_write_rate_kbs: None,
cpu_affinity,
})
}
@@ -1687,4 +1818,151 @@ mod io_sort_unit_tests {
let twice = apply_fold(input, &folded);
assert_eq!(twice.len(), 2);
}
#[test]
fn parse_cpu_list_basic() {
// v1.42. A single range expands correctly.
assert_eq!(parse_cpu_list("0-3"), vec![0, 1, 2, 3]);
}
#[test]
fn parse_cpu_list_mixed_ranges_and_singletons() {
// v1.42. The canonical mixed format used by the
// kernel when a process is pinned to all CPUs
// except one (e.g. CPU 4 left for system use).
assert_eq!(
parse_cpu_list("0-3,5,7-11"),
vec![0, 1, 2, 3, 5, 7, 8, 9, 10, 11]
);
}
#[test]
fn parse_cpu_list_handles_whitespace() {
// v1.42. The kernel never emits whitespace
// inside a Cpus_allowed_list value, but a
// corrupt procfs might. We tolerate leading/
// trailing whitespace and spaces around the
// comma.
assert_eq!(
parse_cpu_list(" 0-3 , 5 , 7-11 "),
vec![0, 1, 2, 3, 5, 7, 8, 9, 10, 11]
);
}
#[test]
fn parse_cpu_list_dedupes_and_sorts() {
// v1.42. A corrupt procfs might emit
// out-of-order or duplicate IDs. We must
// dedupe and sort before displaying.
assert_eq!(
parse_cpu_list("5,3,0-2,5,0"),
vec![0, 1, 2, 3, 5]
);
}
#[test]
fn parse_cpu_list_silently_drops_malformed_chunks() {
// v1.42. A range with start > end is a kernel
// bug. Non-numeric chunks are likewise
// unexpected. We don't crash; we drop them.
assert_eq!(
parse_cpu_list("0-2,5-3,abc,7"),
vec![0, 1, 2, 7]
);
}
#[test]
fn parse_cpu_list_empty_returns_empty() {
// v1.42. The empty string is valid (returns
// empty Vec). The popup distinguishes between
// "no data" (None) and "explicitly empty"
// (Some(empty)) — the empty case is a
// misconfiguration we'd surface.
assert_eq!(parse_cpu_list(""), Vec::<u32>::new());
}
#[test]
fn format_cpu_list_basic() {
// v1.42. Inverse of parse_cpu_list. A
// contiguous range collapses to "start-end".
assert_eq!(format_cpu_list(&[0, 1, 2, 3]), "0-3");
}
#[test]
fn format_cpu_list_mixed() {
// v1.42. Mixed ranges and singletons.
assert_eq!(
format_cpu_list(&[0, 1, 2, 3, 5, 7, 8, 9, 10, 11]),
"0-3,5,7-11"
);
}
#[test]
fn format_cpu_list_empty() {
// v1.42. Empty input returns empty string
// (not "—"). The render layer turns "" into
// the "unknown" marker.
assert_eq!(format_cpu_list(&[]), "");
}
#[test]
fn format_cpu_list_single_id() {
// v1.42. A single CPU ID renders without a
// range dash.
assert_eq!(format_cpu_list(&[5]), "5");
}
#[test]
fn parse_and_format_round_trip() {
// v1.42 regression test. parse → format must
// produce the original kernel string. If the
// inverse isn't true, the Process panel would
// show a different affinity than the kernel
// reports, which would confuse operators.
for original in [
"0",
"0-3",
"0-3,5,7-11",
"0-127",
] {
let parsed = parse_cpu_list(original);
let formatted = format_cpu_list(&parsed);
assert_eq!(formatted, original,
"round-trip failed for {original:?}: \
parsed={parsed:?}, formatted={formatted:?}");
}
}
#[test]
fn read_cpu_affinity_handles_self() {
// v1.42. The test runner's own
// /proc/self/status must be readable and
// contain a Cpus_allowed_list field. The
// parsed list must be non-empty (the kernel
// always grants at least one CPU to every
// process, even if the affinity mask is
// restricted).
let pid = std::process::id();
let affinity = read_cpu_affinity(pid);
if let Some(ids) = affinity {
assert!(!ids.is_empty(),
"test runner's affinity must be non-empty");
// The list must be sorted (parse_cpu_list
// sorts before returning).
for w in ids.windows(2) {
assert!(w[0] <= w[1],
"affinity IDs must be sorted ascending");
}
}
// If None, the test environment doesn't
// expose Cpus_allowed_list (very old kernel).
// That's an environment problem, not a code
// bug.
}
#[test]
fn read_cpu_affinity_returns_none_for_missing_pid() {
// v1.42. None for a non-existent PID.
assert_eq!(read_cpu_affinity(999_999_999), None);
}
}
@@ -943,7 +943,7 @@ pub fn render_process_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> {
_ => "RSS",
};
let header_str = format!(
" PID STATE PRIO NI THR CPU% IO RATE {:<11} T-IO T-IO/s IO-RATE CPU% RSS COMM",
" PID STATE PRIO NI THR CPU% IO RATE {:<11} T-IO T-IO/s IO-RATE CPU% RSS AFF COMM",
mem_header
);
lines.push(Line::from(vec![
@@ -992,6 +992,32 @@ pub fn render_process_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> {
}
_ => "".to_string(),
};
// v1.42: CPU affinity indicator. Single char so
// it doesn't push COMM off the visible area.
// * process is pinned to a subset of CPUs
// (affinity != all CPUs)
// (space) process is on all CPUs (default)
// ? affinity unknown (older kernel / Redox)
// The full list is in the PID detail popup.
let host_cpu_count = crate::acpi::detect_cpus().len();
let affinity_indicator = match &p.cpu_affinity {
None => "?",
Some(ids) => {
// "All CPUs" = the affinity list is
// the same length as the host's CPU
// list. We can't compare specific IDs
// here (host CPUs may have non-
// contiguous IDs on some machines),
// so we approximate by count. If the
// counts match, we assume the process
// is unrestricted.
if ids.len() >= host_cpu_count {
" "
} else {
"*"
}
}
};
let mem_str = match app.process_sort {
crate::process::SortMode::VSize => {
crate::process::ProcessInfo::format_memory_kb(p.vsize_kb)
@@ -1029,7 +1055,7 @@ pub fn render_process_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> {
theme::VALUE
};
lines.push(Line::from(format!(
" {}{:<7} {} {:<4} {:<3} {:<3} {:<6} {:<11} {:<11} {:<11} {:<11} {:<11} {:<12} {:<6} {:<6} {}",
" {}{:<7} {} {:<4} {:<3} {:<3} {:<6} {:<11} {:<11} {:<11} {:<11} {:<11} {:<12} {:<6} {:<5} {:<3} {}",
prefix,
p.pid,
p.state,
@@ -1045,6 +1071,7 @@ pub fn render_process_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> {
io_spark,
cpu_spark,
rss_spark,
affinity_indicator,
comm_truncated,
).set_style(row_style)));
}
@@ -1316,6 +1343,54 @@ pub fn render_pid_detail(detail: &crate::pid_detail::PidDetail, pid: u32) -> Par
opt_kb(&thread_read),
opt_kb(&thread_write),
).set_style(theme::VALUE)));
// v1.42: CPU affinity (full list, expanded form).
// Re-read on popup open so the value is current.
// Show count + compact range string + (on next
// line) the expanded Vec. On small machines the
// expanded list fits inline; on large machines
// (>32 CPUs) we truncate to "first 8 + N more".
let affinity = crate::process::read_cpu_affinity_for_pid(pid);
lines.push(Line::from(""));
lines.push(Line::from("[cpu_affinity]".set_style(theme::LABEL_BOLD)));
match &affinity {
None => {
lines.push(Line::from(" (unavailable)".set_style(theme::VALUE)));
}
Some(ids) if ids.is_empty() => {
lines.push(Line::from(" (empty — process pinned to no CPUs!)"
.set_style(theme::VALUE)));
}
Some(ids) => {
let compact = crate::process::format_cpu_list(ids);
lines.push(Line::from(format!(
" Cpus_allowed_list: {} ({} CPU{})",
compact,
ids.len(),
if ids.len() == 1 { "" } else { "s" },
).set_style(theme::VALUE)));
// Show the expanded Vec. Truncate for
// large machines to keep the popup
// readable.
let display: Vec<u32> = if ids.len() > 8 {
ids.iter().take(8).copied().collect()
} else {
ids.clone()
};
let mut expanded = display
.iter()
.map(|c| c.to_string())
.collect::<Vec<_>>()
.join(", ");
if ids.len() > 8 {
expanded.push_str(&format!(", ... ({} more)",
ids.len() - 8));
}
lines.push(Line::from(format!(
" expanded: [{}]",
expanded
).set_style(theme::VALUE)));
}
}
Paragraph::new(lines)
.block(panel_border(true, " PID Detail "))
.wrap(Wrap { trim: true })