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:
@@ -5697,6 +5697,99 @@ dir within a single refresh cycle.
|
|||||||
per-thread, so this would be a synthetic derivation.
|
per-thread, so this would be a synthetic derivation.
|
||||||
Defer to v1.42 if user demand appears.
|
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
|
## 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.
|
- **`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
|
/// delta of `thread_io_write_kb`. Same sentinel semantics
|
||||||
/// as `thread_io_read_rate_kbs`.
|
/// as `thread_io_read_rate_kbs`.
|
||||||
pub thread_io_write_rate_kbs: Option<f64>,
|
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 {
|
impl ProcessInfo {
|
||||||
@@ -574,6 +585,14 @@ pub fn read_thread_io_for_pid(pid: u32) -> (Option<u64>, Option<u64>) {
|
|||||||
read_thread_io(pid)
|
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`.
|
/// Read per-thread IO aggregated across all threads of `pid`.
|
||||||
/// Walks `/proc/<pid>/task/*/io` and sums `read_bytes` and
|
/// Walks `/proc/<pid>/task/*/io` and sums `read_bytes` and
|
||||||
/// `write_bytes` across TIDs. Returns `(read_kb, write_kb)` as
|
/// `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`
|
/// Compute KiB/s rate from a prev/current sample pair. Returns `None`
|
||||||
/// when either sample is `None` (process just started, /proc/[pid]/io
|
/// when either sample is `None` (process just started, /proc/[pid]/io
|
||||||
/// became readable/unreadable, or first sample after startup) or
|
/// 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
|
// attribution quirk that makes the two sometimes
|
||||||
// diverge).
|
// diverge).
|
||||||
let (thread_io_read_kb, thread_io_write_kb) = read_thread_io(pid);
|
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 {
|
Some(ProcessInfo {
|
||||||
pid,
|
pid,
|
||||||
comm,
|
comm,
|
||||||
@@ -703,6 +833,7 @@ fn parse_stat_line(line: &str) -> Option<ProcessInfo> {
|
|||||||
thread_io_write_kb,
|
thread_io_write_kb,
|
||||||
thread_io_read_rate_kbs: None,
|
thread_io_read_rate_kbs: None,
|
||||||
thread_io_write_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);
|
let twice = apply_fold(input, &folded);
|
||||||
assert_eq!(twice.len(), 2);
|
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",
|
_ => "RSS",
|
||||||
};
|
};
|
||||||
let header_str = format!(
|
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
|
mem_header
|
||||||
);
|
);
|
||||||
lines.push(Line::from(vec![
|
lines.push(Line::from(vec![
|
||||||
@@ -992,6 +992,32 @@ pub fn render_process_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> {
|
|||||||
}
|
}
|
||||||
_ => "—".to_string(),
|
_ => "—".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 {
|
let mem_str = match app.process_sort {
|
||||||
crate::process::SortMode::VSize => {
|
crate::process::SortMode::VSize => {
|
||||||
crate::process::ProcessInfo::format_memory_kb(p.vsize_kb)
|
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
|
theme::VALUE
|
||||||
};
|
};
|
||||||
lines.push(Line::from(format!(
|
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,
|
prefix,
|
||||||
p.pid,
|
p.pid,
|
||||||
p.state,
|
p.state,
|
||||||
@@ -1045,6 +1071,7 @@ pub fn render_process_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> {
|
|||||||
io_spark,
|
io_spark,
|
||||||
cpu_spark,
|
cpu_spark,
|
||||||
rss_spark,
|
rss_spark,
|
||||||
|
affinity_indicator,
|
||||||
comm_truncated,
|
comm_truncated,
|
||||||
).set_style(row_style)));
|
).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_read),
|
||||||
opt_kb(&thread_write),
|
opt_kb(&thread_write),
|
||||||
).set_style(theme::VALUE)));
|
).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)
|
Paragraph::new(lines)
|
||||||
.block(panel_border(true, " PID Detail "))
|
.block(panel_border(true, " PID Detail "))
|
||||||
.wrap(Wrap { trim: true })
|
.wrap(Wrap { trim: true })
|
||||||
|
|||||||
Reference in New Issue
Block a user