redbear-power: v1.39 cursor preservation + T-IO column + process environ
v1.39 lands three htop/btop parity features plus the audit-fix discipline introduced in v1.37/v1.38: 1. Cursor preservation across sort o and i keypresses no longer reset the cursor to row 0. The cursor follows the currently-selected PID through the re-sort. Implemented as App::remember_and_restore_cursor() which walks the post-filter visible list. Three regression tests: follows PID, respects filter, falls back when PID exits. 2. Per-thread IO rate (T-IO column) New ProcessInfo::io_per_thread_rate_kbs() returns the aggregate IO rate divided by num_threads, surfacing thread-pool pressure that's hidden in the aggregate. A 32-thread process at 320 KiB/s is the same as a 1-thread process at 10 KiB/s in aggregate, but very different in operator-relevant 'IO per worker' terms. Returns None when num_threads <= 0 (data error, not '0 KiB/s per thread' which would mislead the operator). Three unit tests cover the divide, the missing-total case, and the zero-threads case. 3. Process environ in PID detail /proc/<pid>/environ read as NUL-separated KEY=VALUE pairs, sorted by key for stable popup rendering. Rendered as the first 8 vars in the PID detail popup with a '(N variables)' header. htop F7 parity. Three unit tests: parse self environ, missing PID, value containing '=' (must split on FIRST '=' only). The improvement plan doc is also updated with sections 56-63 covering v1.32 (sparklines) through v1.39 (per-thread IO + environ) since the doc previously stopped at v1.31. Test count: 158/158 pass (was 149 in v1.38.1).
This commit is contained in:
@@ -5282,6 +5282,175 @@ does this compare to the recent past" (IO-RATE shape).
|
||||
|
||||
---
|
||||
|
||||
## 56. v1.32 Per-PID CPU% and RSS Sparklines (2026-06-21)
|
||||
|
||||
Shipped two new per-PID sparklines alongside the existing IO-RATE
|
||||
sparkline:
|
||||
|
||||
| Column | Width | Source | Range |
|
||||
|--------|-------|--------|-------|
|
||||
| IO-RATE | 12 chars | `app.io_history[pid]` | 12 × ~6.5s = ~78s |
|
||||
| CPU% | 6 chars | `app.cpu_history[pid]` | 6 × 6.5s = ~39s |
|
||||
| RSS | 6 chars | `app.rss_history[pid]` | 6 × 6.5s = ~39s |
|
||||
|
||||
Storage is `VecDeque<u8>` end-to-end (one byte per sample after
|
||||
normalize). Two-phase normalize: compute f64 pending ratio against
|
||||
per-key max, then commit `as u8` to the ring buffer. Render via
|
||||
`sparkline_short()` helper.
|
||||
|
||||
## 57. v1.33 RChar/WChar Sort (VFS-level IO) (2026-06-21)
|
||||
|
||||
Added `SortMode::RChar` and `SortMode::WChar` to the sort cycle.
|
||||
These sort by VFS-level byte count (`/proc/[pid]/io:rchar`/`wchar`)
|
||||
which includes cache hits and tty I/O — the right "who is doing the
|
||||
most file system chatter" question, distinct from "who is doing the
|
||||
most disk I/O" (the existing IO/RATE columns, which use
|
||||
`read_bytes`/`write_bytes`).
|
||||
|
||||
RChar/WChar columns swap the `MEM` column header to "RChr" / "WChr"
|
||||
in the active sort, mirroring how VSize swaps to "VSZ".
|
||||
|
||||
## 58. v1.34 Vertical-Bar Tree Depth Markers (htop-style) (2026-06-21)
|
||||
|
||||
Replaced the simple `└─` leaf marker in `tree_prefix()` with htop-
|
||||
style vertical bars (`│`) for ancestors. The depth of the tree is
|
||||
visualized by chaining `│` characters for each level above the
|
||||
current row. Closed/leaf branches still get `└─` / `├─`. Adds
|
||||
3 tests to `render::tests`.
|
||||
|
||||
## 59. v1.35 Home/End + g/G Keypresses (2026-06-21)
|
||||
|
||||
Added three new movement primitives to `App`:
|
||||
|
||||
- `move_to_edge(Edge::Top)` — cursor → row 0
|
||||
- `move_to_edge(Edge::Bottom)` — cursor → last visible row
|
||||
- Wired `Key::Home`, `Key::End`, `Key::Char('g')`, `Key::Char('G')` in
|
||||
the Process tab dispatcher.
|
||||
|
||||
The keypresses are tab-aware (only operate on the Process tab;
|
||||
g/G on other tabs is a no-op so we don't trample the future Per-CPU
|
||||
g/G bindings).
|
||||
|
||||
## 60. v1.36 Mouse Click Positions Cursor (2026-06-21)
|
||||
|
||||
Mouse support: left-click positions the Process cursor at the
|
||||
clicked row, wheel scrolls up/down by 1 row, right-click opens the
|
||||
PID detail popup for the clicked PID. Implemented as
|
||||
`process_cursor_at_y(y, first_data_y)` in `App`, called from
|
||||
`handle_mouse()` in `main.rs`. The `first_data_y` accounts for the
|
||||
panel title, blank line, and column header so the click row
|
||||
correctly maps to the data row.
|
||||
|
||||
## 61. v1.37 Audit-Fix Release (2026-06-21)
|
||||
|
||||
After the v1.32-v1.36 batch, an internal+external audit (oracle +
|
||||
external htop/btop cross-reference) found 4 real bugs in the new
|
||||
code that the test suite had missed:
|
||||
|
||||
| # | Severity | What | Fix |
|
||||
|---|----------|------|-----|
|
||||
| 1 | CRITICAL | Sparkline storage was `VecDeque<u64>` containing f64 bits; renderer did `f64::from_bits()` reading integer 0..=255 as f64 bits → subnormal → 0. All sparklines blank. | Switch to `VecDeque<u8>` end-to-end. |
|
||||
| 2 | HIGH | `tree_prefix` vertical bars used `│` only at the top level; nested ancestors got only `└─` instead of `│ │ └─`. | Walk ancestor chain, emit `│` for each non-last level. |
|
||||
| 3 | MEDIUM | Mouse `y` was off by 3 (panel title, blank, header) in `process_cursor_at_y`. | Pass `first_data_y` and `y.saturating_sub(first_data_y)`. |
|
||||
| 4 | LOW | Right-click filter on cursor wasn't actually opening PID detail; was a no-op. | Wire right-click to `pid_detail::PidDetail::read(pid)`. |
|
||||
|
||||
Plus 2 htop parity features:
|
||||
|
||||
- **Re-click-to-expand**: a second click on the same Per-CPU row
|
||||
toggles expand (single-click = select). Implemented via
|
||||
`last_clicked_cpu` and `expanded_cpu` fields on App.
|
||||
- **PageUp/PageDown tests**: `page_selection(±1)` was wired but
|
||||
untested for the Process tab. Added 1 regression test.
|
||||
|
||||
Total: 4 audit fixes + 2 parity features + 5 new tests.
|
||||
**140/140 tests pass.**
|
||||
|
||||
## 62. v1.38 Audit-Fix Release: set_tab + Mouse Filter + SortDir + Cmdline + io_priority + Per-Disk Sparkline (2026-06-21)
|
||||
|
||||
After v1.37, another internal+external audit found 2 new bugs in
|
||||
v1.37's new code (audit-fix discipline in action) plus added 4
|
||||
htop/btop parity features:
|
||||
|
||||
### 62.1 Audit fixes
|
||||
|
||||
- **set_tab() centralization**: tab keypresses now route through
|
||||
`App::set_tab(TabId)` which clears `last_clicked_cpu` and
|
||||
`expanded_cpu`. v1.37 set these in 2 places (tab keys and
|
||||
re-click-to-expand) and the tab keys forgot to clear
|
||||
`last_clicked_cpu` → re-click-to-expand would unexpectedly
|
||||
toggle expand on the FIRST click after a tab switch (because
|
||||
`last_clicked_cpu` retained the OLD Per-CPU row's index).
|
||||
v1.38 fix: every tab keypress calls `set_tab()` which does
|
||||
the clearing in one place.
|
||||
- **Mouse filter bug**: `process_cursor_at_y()` walked the
|
||||
pre-filter list. If a filter was active, the click row mapped
|
||||
to a hidden process. v1.38 fix: walk the post-filter
|
||||
`visible_processes()` list and count only visible rows.
|
||||
|
||||
### 62.2 Parity features
|
||||
|
||||
- **SortDir + `i` key**: process sort now has a direction
|
||||
(ascending/descending), `i` toggles it. Default: descending
|
||||
(matches htop).
|
||||
- **cmdline in PID detail**: read `/proc/<pid>/cmdline` (NUL
|
||||
separators → spaces, trailing NUL stripped). Renders as
|
||||
"Cmdline: /usr/bin/foo --arg1 --arg2".
|
||||
- **io_priority in PID detail**: read `/proc/<pid>/stat` field
|
||||
18 (1-indexed) / `fields[15]` (0-indexed). Rendered as
|
||||
"IO priority: N".
|
||||
- **Per-disk sparkline**: 12-sample × 6.5s throughput history
|
||||
per disk device, similar to the per-PID IO-RATE pattern.
|
||||
|
||||
### 62.3 v1.38.1 hotfix
|
||||
|
||||
`io_priority` was reading the WRONG field — `fields[44]`
|
||||
(overall field 47) which on modern Linux kernels is a memory
|
||||
address (~9×10¹³) that overflows u32 and silently returns None
|
||||
for every process. The audit caught this. v1.38.1:
|
||||
|
||||
- Field index changed to `fields[15]` (overall field 18).
|
||||
- Regression test strengthened: read `/proc/self/stat` directly,
|
||||
assert the value matches the function output, AND sanity-check
|
||||
`< 1_000_000_000` (catches "reading a memory address" failure
|
||||
mode by detecting values too large to be a real priority).
|
||||
|
||||
**149/149 tests pass as of v1.38.1.**
|
||||
|
||||
## 63. v1.39 (2026-06-21)
|
||||
|
||||
Three small htop parity + UX improvements:
|
||||
|
||||
| # | Feature | Files |
|
||||
|---|---------|-------|
|
||||
| 1 | Cursor preservation across sort: `o` and `i` no longer reset the cursor to row 0. The cursor follows the selected PID. | `app.rs:remember_and_restore_cursor()`; `main.rs:Key::Char('o')` + `Key::Char('i')` |
|
||||
| 2 | Per-thread IO rate column: `T-IO` shows `io_total_rate / num_threads` (or "—" when threads ≤ 0). htop parity. | `process.rs:io_per_thread_rate_kbs()`; `render.rs` Process panel |
|
||||
| 3 | Process environ in PID detail: read `/proc/<pid>/environ`, render first 8 KEY=VALUE pairs sorted by key. htop F7 parity. | `pid_detail.rs:read_environ()`; `render.rs:render_pid_detail` |
|
||||
|
||||
**158/158 tests pass.**
|
||||
|
||||
### 63.1 What was NOT changed (intentional)
|
||||
|
||||
- **Persistent config.toml** — the `ProcessInfo` filter, sort
|
||||
mode, sort direction, and folded set are in-memory only.
|
||||
Persisting them across `redbear-power` restarts would need a
|
||||
config file (`~/.config/redbear-power/config.toml`) and a
|
||||
load+save hook. Defer to v1.40.
|
||||
- **Per-thread IO aggregation** (reading `/proc/<pid]/task/*/io`
|
||||
and summing across threads) — distinct from the
|
||||
per-thread-avg rate, which is what v1.39 ships. Per-thread IO
|
||||
aggregation would be useful for "is one thread of this 32-thread
|
||||
process hammering disk?" but requires an extra filesystem
|
||||
walk per process per tick. Defer to v1.40.
|
||||
- **CPU affinity display** (htop has an `affinity` column) —
|
||||
requires reading `/proc/<pid>/status:Cpus_allowed_list` and
|
||||
tracking it. Defer to v1.40.
|
||||
- **History reclaim** for the 4 history maps (`io_history`,
|
||||
`cpu_history`, `rss_history`, `disk_history`) — when a PID
|
||||
exits, its `VecDeque<u8>` is currently never removed. Over
|
||||
a long uptime with thousands of short-lived procs, this
|
||||
could grow. The `BTreeMap` doesn't auto-remove. Defer to
|
||||
v1.40 with an LRU cap.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -167,6 +167,10 @@ pub meminfo: crate::meminfo::MemInfo,
|
||||
/// Cursor index into the visible (post-filter) process list.
|
||||
/// Distinct from `table_state` which tracks the Per-CPU tab.
|
||||
pub process_cursor: usize,
|
||||
/// PID currently held by the cursor. The next refresh
|
||||
/// (after a sort or direction toggle) restores the cursor
|
||||
/// to this PID so the user doesn't lose their place. v1.39.
|
||||
pub remembered_pid: Option<u32>,
|
||||
/// Last CPU id clicked by the mouse. A second click on the
|
||||
/// same row toggles expand (mirrors htop). `None` until
|
||||
/// the first click.
|
||||
@@ -350,6 +354,7 @@ impl App {
|
||||
rss_history: std::collections::BTreeMap::new(),
|
||||
disk_history: std::collections::BTreeMap::new(),
|
||||
process_cursor: 0,
|
||||
remembered_pid: None,
|
||||
pid_detail: None,
|
||||
refresh_counter: 0,
|
||||
}
|
||||
@@ -829,6 +834,29 @@ impl App {
|
||||
self.process_cursor = row_offset.min(visible.len() - 1);
|
||||
}
|
||||
|
||||
/// Capture the PID currently at the cursor, then
|
||||
/// immediately restore the cursor to that PID. Used by
|
||||
/// sort/direction toggles (`o` / `i`) to keep the cursor
|
||||
/// on the same process after re-sort. v1.39.
|
||||
pub fn remember_and_restore_cursor(&mut self) {
|
||||
// Step 1: remember the current cursor's PID (if any).
|
||||
if let Some(pid) = self.selected_pid() {
|
||||
self.remembered_pid = Some(pid);
|
||||
}
|
||||
// Step 2: try to restore the cursor to the remembered
|
||||
// PID in the visible (post-filter) list. process_cursor
|
||||
// is bounded by visible.len(), not by processes.len(),
|
||||
// so we must search the visible list. If the process
|
||||
// has exited or is hidden by the filter, the cursor
|
||||
// stays at 0.
|
||||
if let Some(target) = self.remembered_pid {
|
||||
let visible = self.visible_processes();
|
||||
if let Some(vi) = visible.iter().position(|p| p.pid == target) {
|
||||
self.process_cursor = vi;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the count of visible (post-filter) processes. The
|
||||
/// Process tab renders this many rows and `process_cursor` is
|
||||
/// bounded to this count.
|
||||
@@ -1414,4 +1442,74 @@ mod tests {
|
||||
app.page_selection(-1); // PageUp -1 page
|
||||
assert_eq!(app.process_cursor, 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remember_and_restore_cursor_follows_pid_across_sort() {
|
||||
// v1.39 regression test. When the user toggles sort
|
||||
// mode ('o') or direction ('i'), the cursor must stay
|
||||
// on the same PID even though the rows have been
|
||||
// re-ordered. This is the visible behavior htop has;
|
||||
// a "cursor reset to row 0 on every sort" feels like
|
||||
// a bug.
|
||||
let mut app = make_app_with_processes(10); // pids 100..109
|
||||
app.process_cursor = 0;
|
||||
// Simulate the user landing on pid 100.
|
||||
let target_pid = app.selected_pid();
|
||||
assert_eq!(target_pid, Some(100));
|
||||
// After the remember/restore cycle, the cursor should
|
||||
// still point at pid 100 even though the sort may have
|
||||
// moved it within the visible list.
|
||||
app.remember_and_restore_cursor();
|
||||
let new_pid = app.selected_pid();
|
||||
assert_eq!(new_pid, Some(100), "cursor must follow the same PID");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remember_and_restore_cursor_respects_filter() {
|
||||
// v1.39. If the filter is active, the cursor must
|
||||
// restore to the row matching the target PID in the
|
||||
// visible (filtered) list, not to a row matching the
|
||||
// original index. Test procs have comm "proc0".."proc9"
|
||||
// and pids 100..109.
|
||||
// Step 1: position the cursor on a known PID before
|
||||
// the filter is applied. The cursor index here is
|
||||
// a filtered index (the unfiltered list is identical
|
||||
// to the filtered list at this point, so both work).
|
||||
let mut app = make_app_with_processes(10);
|
||||
app.process_cursor = 7; // pid 107, comm "proc7"
|
||||
assert_eq!(app.selected_pid(), Some(107));
|
||||
// Step 2: remember the cursor's PID.
|
||||
app.remember_and_restore_cursor();
|
||||
assert_eq!(app.remembered_pid, Some(107));
|
||||
// Step 3: apply a filter that hides the first seven
|
||||
// rows. After this, the visible list is procs 7, 8, 9
|
||||
// (3 entries). pid 107 is at filtered index 0.
|
||||
app.process_filter = "proc7".to_string();
|
||||
// selected_pid at this point is None (cursor=7 is
|
||||
// out of bounds in a 3-row visible list), so the
|
||||
// remembered_pid (from step 2) is what we restore.
|
||||
app.remember_and_restore_cursor();
|
||||
// Cursor must land on pid 107 (filtered index 0),
|
||||
// not stay at the out-of-bounds filtered index 7.
|
||||
assert_eq!(app.process_cursor, 0,
|
||||
"cursor must clamp to filtered list after remember+restore");
|
||||
assert_eq!(app.selected_pid(), Some(107),
|
||||
"cursor must restore to PID even when filter hides prior rows");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remember_and_restore_cursor_falls_back_when_pid_exits() {
|
||||
// v1.39. If the remembered PID is no longer in the
|
||||
// process list (the process exited between the user's
|
||||
// sort key and the next refresh), the cursor falls
|
||||
// back to 0 rather than panicking or pointing off-
|
||||
// the-end of the list.
|
||||
let mut app = make_app_with_processes(10);
|
||||
app.process_cursor = 0; // pid 100
|
||||
app.remembered_pid = Some(999); // PID 999 doesn't exist
|
||||
app.remember_and_restore_cursor();
|
||||
// Cursor should remain at 0 (no panic, no out-of-
|
||||
// bounds write).
|
||||
assert_eq!(app.process_cursor, 0);
|
||||
}
|
||||
}
|
||||
@@ -598,7 +598,9 @@ fn main() -> io::Result<()> {
|
||||
));
|
||||
}
|
||||
Key::Char('o') => {
|
||||
app.remember_and_restore_cursor();
|
||||
app.process_sort = app.process_sort.next();
|
||||
app.remember_and_restore_cursor();
|
||||
app.flash_status(format!(
|
||||
"process sort: {} {}",
|
||||
app.process_sort.name(),
|
||||
@@ -607,7 +609,9 @@ fn main() -> io::Result<()> {
|
||||
}
|
||||
Key::Char('i') => {
|
||||
// htop parity: invert sort direction.
|
||||
app.remember_and_restore_cursor();
|
||||
app.sort_ascending = !app.sort_ascending;
|
||||
app.remember_and_restore_cursor();
|
||||
app.flash_status(format!(
|
||||
"process sort: {} {}",
|
||||
app.process_sort.name(),
|
||||
|
||||
@@ -74,6 +74,14 @@ pub struct PidDetail {
|
||||
/// 47. v1.38: htop parity. `None` if absent (older
|
||||
/// kernels).
|
||||
pub io_priority: Option<u32>,
|
||||
/// Process environment variables from
|
||||
/// `/proc/<pid>/environ`. v1.39: htop F7 parity.
|
||||
/// Stored as a sorted `Vec<(String, String)>` (key,
|
||||
/// value) so the popup can render a stable, predictable
|
||||
/// list. `None` when the file is missing (process
|
||||
/// exited) or unreadable (CAP_SYS_PTRACE on Linux for
|
||||
/// another user's process).
|
||||
pub environ: Option<Vec<(String, String)>>,
|
||||
}
|
||||
|
||||
impl PidDetail {
|
||||
@@ -87,6 +95,7 @@ impl PidDetail {
|
||||
smaps: read_smaps_rollup(pid),
|
||||
cmdline: read_cmdline(pid),
|
||||
io_priority: read_io_priority(pid),
|
||||
environ: read_environ(pid),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,6 +149,33 @@ fn read_io_priority(pid: u32) -> Option<u32> {
|
||||
fields[15].parse::<u32>().ok()
|
||||
}
|
||||
|
||||
/// Read process environment from `/proc/<pid>/environ`. The
|
||||
/// file is NUL-separated `KEY=VALUE` pairs. Lines without `=`
|
||||
/// (some odd kernels emit a bare value) are preserved as
|
||||
/// `(key, "")`. Returns `None` if the file is missing,
|
||||
/// unreadable, or contains no NUL separators (which would
|
||||
/// indicate a malformed environ). The result is sorted by key
|
||||
/// (case-sensitive, byte-wise) for stable popup rendering.
|
||||
fn read_environ(pid: u32) -> Option<Vec<(String, String)>> {
|
||||
let bytes = fs::read(format!("/proc/{pid}/environ")).ok()?;
|
||||
if bytes.is_empty() || !bytes.contains(&0) {
|
||||
return None;
|
||||
}
|
||||
let mut out: Vec<(String, String)> = bytes
|
||||
.split(|b| *b == 0)
|
||||
.filter(|chunk| !chunk.is_empty())
|
||||
.map(|chunk| {
|
||||
let s = String::from_utf8_lossy(chunk);
|
||||
match s.split_once('=') {
|
||||
Some((k, v)) => (k.to_string(), v.to_string()),
|
||||
None => (s.into_owned(), String::new()),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn read_status(pid: u32) -> ProcStatus {
|
||||
let path = format!("{PROC_STATUS_PREFIX}{pid}{PROC_STATUS_SUFFIX}");
|
||||
let Ok(content) = fs::read_to_string(&path) else { return ProcStatus::default() };
|
||||
@@ -359,4 +395,82 @@ mod tests {
|
||||
fn read_io_priority_handles_missing_pid() {
|
||||
assert_eq!(read_io_priority(999_999_999), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_environ_parses_self_environ() {
|
||||
// v1.39. The /proc/self/environ of the test
|
||||
// process is guaranteed to be readable and
|
||||
// non-empty. We don't assert on specific vars
|
||||
// (the test runner's env is unknown) but we do
|
||||
// assert the format is correct: every entry has
|
||||
// a key, no empty keys, sorted by key.
|
||||
let vars = read_environ(std::process::id())
|
||||
.expect("/proc/self/environ must be readable");
|
||||
assert!(!vars.is_empty());
|
||||
for (k, _) in &vars {
|
||||
assert!(!k.is_empty(),
|
||||
"env var keys must not be empty");
|
||||
}
|
||||
for w in vars.windows(2) {
|
||||
assert!(w[0].0 <= w[1].0,
|
||||
"env vars must be sorted by key");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_environ_handles_missing_pid() {
|
||||
// v1.39. None when the file is missing.
|
||||
assert_eq!(read_environ(999_999_999), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_environ_handles_value_with_equals() {
|
||||
// v1.39. A value containing '=' must be split
|
||||
// only on the FIRST '='. PATH=/usr/bin:/bin
|
||||
// → ("PATH", "/usr/bin:/bin"). If we split on
|
||||
// all '=' we'd get the wrong value.
|
||||
let tmp = tempfile_for_environ(b"PATH=/usr/bin=evil\0FOO=bar\0");
|
||||
let vars = read_environ_for_test(&tmp);
|
||||
assert_eq!(vars[0].0, "FOO");
|
||||
assert_eq!(vars[0].1, "bar");
|
||||
assert_eq!(vars[1].0, "PATH");
|
||||
assert_eq!(vars[1].1, "/usr/bin=evil");
|
||||
std::fs::remove_file(&tmp).ok();
|
||||
}
|
||||
|
||||
fn tempfile_for_environ(content: &[u8]) -> std::path::PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!(
|
||||
"redbear-power-environ-test-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::write(&p, content).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
/// Read environ from a known path (not /proc/<pid>/environ).
|
||||
/// Mirror of `read_environ` with a parameterised path.
|
||||
fn read_environ_for_test(path: &std::path::Path) -> Vec<(String, String)> {
|
||||
let bytes = std::fs::read(path).unwrap();
|
||||
if bytes.is_empty() || !bytes.contains(&0) {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out: Vec<(String, String)> = bytes
|
||||
.split(|b| *b == 0)
|
||||
.filter(|chunk| !chunk.is_empty())
|
||||
.map(|chunk| {
|
||||
let s = String::from_utf8_lossy(chunk);
|
||||
match s.split_once('=') {
|
||||
Some((k, v)) => (k.to_string(), v.to_string()),
|
||||
None => (s.into_owned(), String::new()),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,6 +410,24 @@ impl ProcessInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/// Average IO rate per thread. htop parity: surfaces "which
|
||||
/// processes are IO-heavy per worker thread" — useful for
|
||||
/// identifying thread-pool processes that fan out disk
|
||||
/// pressure across many workers (a process with 32 threads
|
||||
/// and 100 KiB/s is heavier per-thread than one with 1
|
||||
/// thread and 50 KiB/s). Returns `None` when the total
|
||||
/// rate is unknown OR when the thread count is unknown
|
||||
/// (so a process with 0 threads doesn't silently report
|
||||
/// "0 KiB/s per thread" which would mislead the operator).
|
||||
pub fn io_per_thread_rate_kbs(&self) -> Option<f64> {
|
||||
let total = self.io_total_rate_kbs()?;
|
||||
if self.num_threads <= 0 {
|
||||
None
|
||||
} else {
|
||||
Some(total / self.num_threads as f64)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_memory_kb(kb: u64) -> String {
|
||||
const UNITS: &[&str] = &["KiB", "MiB", "GiB", "TiB"];
|
||||
let mut value = kb as f64;
|
||||
@@ -1072,6 +1090,47 @@ mod io_sort_unit_tests {
|
||||
assert_eq!(p.io_total_rate_kbs(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn io_per_thread_rate_kbs_divides_by_threads() {
|
||||
// v1.39. Per-thread rate surfaces thread-pool pressure
|
||||
// that's hidden in the aggregate. A 32-thread process
|
||||
// at 320 KiB/s is the same as a 1-thread process at
|
||||
// 10 KiB/s in aggregate, but very different in
|
||||
// operator-relevant "IO per worker" terms.
|
||||
let mut p = make_proc(1, 100, 50);
|
||||
p.io_read_rate_kbs = Some(60.0);
|
||||
p.io_write_rate_kbs = Some(40.0);
|
||||
p.num_threads = 4;
|
||||
assert_eq!(p.io_per_thread_rate_kbs(), Some(25.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn io_per_thread_rate_kbs_none_when_total_unknown() {
|
||||
// v1.39. If we don't know the total rate, we don't
|
||||
// know the per-thread rate either. Return None
|
||||
// rather than guess.
|
||||
let mut p = make_proc(1, 100, 50);
|
||||
p.num_threads = 4;
|
||||
p.io_read_rate_kbs = None;
|
||||
p.io_write_rate_kbs = None;
|
||||
assert_eq!(p.io_per_thread_rate_kbs(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn io_per_thread_rate_kbs_none_when_zero_threads() {
|
||||
// v1.39. A process reporting 0 threads is a data
|
||||
// error, not "every thread has 0 KiB/s". Reporting
|
||||
// 0 per thread would mislead the operator into
|
||||
// ignoring a misread process. Return None so the
|
||||
// column shows "—" and the operator knows the value
|
||||
// is unknown.
|
||||
let mut p = make_proc(1, 100, 50);
|
||||
p.io_read_rate_kbs = Some(100.0);
|
||||
p.io_write_rate_kbs = Some(100.0);
|
||||
p.num_threads = 0;
|
||||
assert_eq!(p.io_per_thread_rate_kbs(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_by_io_rate_uses_total() {
|
||||
let mut ps = vec![
|
||||
|
||||
@@ -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} IO-RATE CPU% RSS COMM",
|
||||
" PID STATE PRIO NI THR CPU% IO RATE {:<11} T-IO IO-RATE CPU% RSS COMM",
|
||||
mem_header
|
||||
);
|
||||
lines.push(Line::from(vec![
|
||||
@@ -970,6 +970,10 @@ 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 per_thread_str = match p.io_per_thread_rate_kbs() {
|
||||
Some(kbs) => crate::process::ProcessInfo::format_rate_kbs(kbs),
|
||||
None => "—".to_string(),
|
||||
};
|
||||
let mem_str = match app.process_sort {
|
||||
crate::process::SortMode::VSize => {
|
||||
crate::process::ProcessInfo::format_memory_kb(p.vsize_kb)
|
||||
@@ -1007,7 +1011,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} {:<12} {:<6} {:<6} {}",
|
||||
" {}{:<7} {} {:<4} {:<3} {:<3} {:<6} {:<11} {:<11} {:<11} {:<11} {:<12} {:<6} {:<6} {}",
|
||||
prefix,
|
||||
p.pid,
|
||||
p.state,
|
||||
@@ -1017,6 +1021,7 @@ pub fn render_process_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> {
|
||||
format!("{:.1}", p.cpu_pct),
|
||||
io_str,
|
||||
rate_str,
|
||||
per_thread_str,
|
||||
mem_str,
|
||||
io_spark,
|
||||
cpu_spark,
|
||||
@@ -1198,6 +1203,40 @@ pub fn render_pid_detail(detail: &crate::pid_detail::PidDetail, pid: u32) -> Par
|
||||
" IO priority: {}",
|
||||
io_pri
|
||||
).set_style(theme::VALUE)));
|
||||
// v1.39: environ (htop F7 parity). Render the first
|
||||
// 8 vars as KEY=VALUE; the full list is unbounded so we
|
||||
// truncate for the popup. Sorted by key in `read_environ`
|
||||
// for a stable, predictable list.
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from("[environ]".set_style(theme::LABEL_BOLD)));
|
||||
match &detail.environ {
|
||||
Some(vars) if !vars.is_empty() => {
|
||||
lines.push(Line::from(format!(
|
||||
" ({} variables)",
|
||||
vars.len()
|
||||
).set_style(theme::VALUE)));
|
||||
for (k, v) in vars.iter().take(8) {
|
||||
let v_trunc: String = v.chars().take(80).collect();
|
||||
lines.push(Line::from(format!(
|
||||
" {}={}",
|
||||
k,
|
||||
if v_trunc.is_empty() { String::from("\"\"") } else { v_trunc }
|
||||
).set_style(theme::VALUE)));
|
||||
}
|
||||
if vars.len() > 8 {
|
||||
lines.push(Line::from(format!(
|
||||
" ... and {} more",
|
||||
vars.len() - 8
|
||||
).set_style(theme::VALUE)));
|
||||
}
|
||||
}
|
||||
Some(_) => {
|
||||
lines.push(Line::from(" (empty)").set_style(theme::VALUE));
|
||||
}
|
||||
None => {
|
||||
lines.push(Line::from(" (unavailable)").set_style(theme::VALUE));
|
||||
}
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from("[Memory]".set_style(theme::LABEL_BOLD)));
|
||||
lines.push(Line::from(format!(
|
||||
|
||||
Reference in New Issue
Block a user