From 0771fa2ff656be99fb467f411f907d9eea02ff2c Mon Sep 17 00:00:00 2001 From: vasilito Date: Sun, 21 Jun 2026 13:38:24 +0300 Subject: [PATCH] redbear-power: v1.42 CPU affinity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The next item from the v1.41 deferred list: read /proc//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> 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. --- local/docs/redbear-power-improvement-plan.md | 93 ++++++ .../redbear-power/source/src/process.rs | 278 ++++++++++++++++++ .../system/redbear-power/source/src/render.rs | 79 ++++- 3 files changed, 448 insertions(+), 2 deletions(-) diff --git a/local/docs/redbear-power-improvement-plan.md b/local/docs/redbear-power-improvement-plan.md index c7d7fc7006..496b439fae 100644 --- a/local/docs/redbear-power-improvement-plan.md +++ b/local/docs/redbear-power-improvement-plan.md @@ -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//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` 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. diff --git a/local/recipes/system/redbear-power/source/src/process.rs b/local/recipes/system/redbear-power/source/src/process.rs index e7f278c1d0..b222f68c41 100644 --- a/local/recipes/system/redbear-power/source/src/process.rs +++ b/local/recipes/system/redbear-power/source/src/process.rs @@ -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, + /// CPU affinity parsed from + /// `/proc//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>, } impl ProcessInfo { @@ -574,6 +585,14 @@ pub fn read_thread_io_for_pid(pid: u32) -> (Option, Option) { read_thread_io(pid) } +/// Public wrapper for `read_cpu_affinity` used by the PID +/// detail popup. v1.42. Returns the same `Option>` +/// shape, so the popup can distinguish "no data" (None) +/// from "explicitly empty" (Some(empty)). +pub fn read_cpu_affinity_for_pid(pid: u32) -> Option> { + read_cpu_affinity(pid) +} + /// Read per-thread IO aggregated across all threads of `pid`. /// Walks `/proc//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, Option) { } } +/// Read CPU affinity from +/// `/proc//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> { + 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 { + let mut out: Vec = 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::() { + Ok(n) => n, + Err(_) => continue, + }; + let hi = match hi.trim().parse::() { + 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::() { + 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 { // 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//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 { 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::::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); + } } diff --git a/local/recipes/system/redbear-power/source/src/render.rs b/local/recipes/system/redbear-power/source/src/render.rs index d2fe33bd3a..50416d13e3 100644 --- a/local/recipes/system/redbear-power/source/src/render.rs +++ b/local/recipes/system/redbear-power/source/src/render.rs @@ -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 = if ids.len() > 8 { + ids.iter().take(8).copied().collect() + } else { + ids.clone() + }; + let mut expanded = display + .iter() + .map(|c| c.to_string()) + .collect::>() + .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 })