From 66c996e6ae24ae496aa6b7ed07c85dce2b106d13 Mon Sep 17 00:00:00 2001 From: main Date: Sat, 25 Apr 2026 13:27:32 -0400 Subject: Release memview 1.0.0 --- crates/memview/Cargo.toml | 28 + crates/memview/README.md | 23 + crates/memview/src/app.rs | 1774 ++++++++++++++++++++++++++++++++++++++ crates/memview/src/app/rows.rs | 484 +++++++++++ crates/memview/src/app/tests.rs | 390 +++++++++ crates/memview/src/app/worker.rs | 368 ++++++++ crates/memview/src/main.rs | 160 ++++ crates/memview/src/model.rs | 480 +++++++++++ crates/memview/src/nav.rs | 217 +++++ crates/memview/src/probe.rs | 1417 ++++++++++++++++++++++++++++++ crates/memview/src/search.rs | 130 +++ crates/memview/src/ui.rs | 1076 +++++++++++++++++++++++ 12 files changed, 6547 insertions(+) create mode 100644 crates/memview/Cargo.toml create mode 100644 crates/memview/README.md create mode 100644 crates/memview/src/app.rs create mode 100644 crates/memview/src/app/rows.rs create mode 100644 crates/memview/src/app/tests.rs create mode 100644 crates/memview/src/app/worker.rs create mode 100644 crates/memview/src/main.rs create mode 100644 crates/memview/src/model.rs create mode 100644 crates/memview/src/nav.rs create mode 100644 crates/memview/src/probe.rs create mode 100644 crates/memview/src/search.rs create mode 100644 crates/memview/src/ui.rs (limited to 'crates') diff --git a/crates/memview/Cargo.toml b/crates/memview/Cargo.toml new file mode 100644 index 0000000..a834f25 --- /dev/null +++ b/crates/memview/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "memview" +description = "Linux-only ncdu-like TUI for attributing RAM across processes, tmpfs, shm, and kernel counters" +edition.workspace = true +keywords = ["linux", "memory", "procfs", "tmpfs", "tui"] +categories = ["command-line-utilities"] +license.workspace = true +readme = "README.md" +repository = "https://git.swarm.moe/memview" +rust-version.workspace = true +version.workspace = true + +[dependencies] +clap = { version = "4.6.1", features = ["derive"] } +color-eyre = "0.6.5" +crossterm = "0.29.0" +ratatui = { version = "0.30.0", default-features = false, features = ["crossterm"] } +regex = "1.12.2" +rustix = { version = "1.1.4", features = ["process"] } +uzers = "0.12.2" +walkdir = "2.5.0" + +[lints] +workspace = true + +[package.metadata.docs.rs] +default-target = "x86_64-unknown-linux-gnu" +targets = ["x86_64-unknown-linux-gnu"] diff --git a/crates/memview/README.md b/crates/memview/README.md new file mode 100644 index 0000000..3ba685a --- /dev/null +++ b/crates/memview/README.md @@ -0,0 +1,23 @@ +# memview + +`memview` is a Linux-only terminal UI for attributing RAM usage across the +surfaces that usually make totals feel impossible to reconcile: processes, +tmpfs, shared mappings, SysV shm, and kernel counters. + +It is intentionally not cross-platform. The tool reads Linux `/proc`, tmpfs +mount metadata, smaps on demand, and pidfd process-control surfaces. + +## Install + +```bash +cargo install --path crates/memview --locked --profile release +``` + +## Use + +```bash +memview +``` + +The default pane is `Processes`. Press `?` inside the TUI for global and +pane-specific controls. diff --git a/crates/memview/src/app.rs b/crates/memview/src/app.rs new file mode 100644 index 0000000..f52afa5 --- /dev/null +++ b/crates/memview/src/app.rs @@ -0,0 +1,1774 @@ +use crate::model::{ + Bytes, LedgerState, Meminfo, Metric, ObjectKind, ObjectUsage, Pid, ProcessNode, SharedObject, + Snapshot, TmpfsMount, TmpfsNode, TmpfsNodeKind, +}; +pub use crate::nav::{Hotkey, HotkeySections, Tab}; +use crate::probe; +use crate::search::{Search, SearchDraft, SearchRole, SearchSummary}; +use color_eyre::eyre::Result; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}; +use rustix::fd::OwnedFd; +use rustix::process::{Pid as KernelPid, PidfdFlags, Signal, pidfd_open, pidfd_send_signal}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{self, Receiver, Sender, TryRecvError}; +use std::thread; +use std::time::{Duration, Instant, SystemTime}; + +mod rows; +mod worker; +use rows::{build_process_rows, build_shared_rows, build_tmpfs_rows}; +pub use worker::{WorkerCommand, WorkerEvent, spawn_worker}; + +const KILL_ARMING_DELAY: Duration = Duration::from_secs(2); +const TERMINAL_FRAME_ROWS: u16 = 9; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProcessScope { + SelfOnly, + SelfAndChildren, +} + +impl ProcessScope { + #[must_use] + pub fn next(self) -> Self { + match self { + Self::SelfOnly => Self::SelfAndChildren, + Self::SelfAndChildren => Self::SelfOnly, + } + } + + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::SelfOnly => "self", + Self::SelfAndChildren => "self+children", + } + } + + #[must_use] + pub fn rollup(self, node: &ProcessNode) -> crate::model::MemoryRollup { + match self { + Self::SelfOnly => node.rollup, + Self::SelfAndChildren => node.subtree, + } + } +} + +#[derive(Clone, Debug)] +pub struct FlatProcessRow { + pub index: usize, + pub pid: Pid, + pub depth: usize, + pub fold: RowFold, + pub search: SearchRole, +} + +#[derive(Clone, Debug)] +pub struct FlatTmpfsRow { + pub mount_index: usize, + pub path: PathBuf, + pub name: String, + pub kind: TmpfsNodeKind, + pub allocated: Bytes, + pub logical: Bytes, + pub depth: usize, + pub fold: RowFold, + pub search: SearchRole, +} + +#[derive(Clone, Debug)] +pub struct FlatSharedRow { + pub index: usize, + key: (ObjectKind, String), + pub search: SearchRole, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RowFold { + Leaf, + Expanded, + Collapsed, +} + +impl RowFold { + #[must_use] + fn is_collapsed(self) -> bool { + self == Self::Collapsed + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RowIndex(usize); + +impl RowIndex { + #[must_use] + fn new(index: usize) -> Self { + Self(index) + } + + #[must_use] + pub fn get(self) -> usize { + self.0 + } +} + +pub trait IdentifiedRow { + type Key: Clone + Ord; + + fn key(&self) -> &Self::Key; +} + +impl IdentifiedRow for FlatProcessRow { + type Key = Pid; + + fn key(&self) -> &Self::Key { + &self.pid + } +} + +impl IdentifiedRow for FlatTmpfsRow { + type Key = PathBuf; + + fn key(&self) -> &Self::Key { + &self.path + } +} + +impl IdentifiedRow for FlatSharedRow { + type Key = (ObjectKind, String); + + fn key(&self) -> &Self::Key { + &self.key + } +} + +#[derive(Clone, Debug)] +pub struct PaneRows { + rows: Vec, + by_key: BTreeMap, + selected: Option, +} + +impl Default for PaneRows { + fn default() -> Self { + Self { + rows: Vec::new(), + by_key: BTreeMap::new(), + selected: None, + } + } +} + +impl PaneRows { + #[must_use] + pub fn rows(&self) -> &[Row] { + &self.rows + } + + #[must_use] + pub fn selected(&self) -> Option<&Row> { + self.selected.and_then(|index| self.rows.get(index.get())) + } + + #[must_use] + pub fn selected_index(&self) -> usize { + self.selected.map_or(0, RowIndex::get) + } + + fn selected_slot(&self) -> Option { + self.selected + } + + fn selected_key(&self) -> Option { + self.selected().map(|row| row.key().clone()) + } + + fn install(&mut self, rows: Vec) { + self.install_prefer(rows, self.selected_key()); + } + + fn install_pinned_to_top(&mut self, rows: Vec) { + self.install_prefer(rows, None); + } + + fn install_prefer(&mut self, rows: Vec, preferred: Option) { + let by_key = rows + .iter() + .enumerate() + .map(|(index, row)| (row.key().clone(), RowIndex::new(index))) + .collect::>(); + let selected = preferred + .and_then(|key| by_key.get(&key).copied()) + .or_else(|| (!rows.is_empty()).then_some(RowIndex::new(0))); + + self.rows = rows; + self.by_key = by_key; + self.selected = selected; + } + + fn select_clamped(&mut self, index: RowIndex) -> bool { + if self.rows.is_empty() { + let changed = self.selected.is_some(); + self.selected = None; + return changed; + } + let next = RowIndex::new(index.get().min(self.rows.len() - 1)); + let changed = self.selected != Some(next); + self.selected = Some(next); + changed + } + + fn move_by(&mut self, delta: isize) -> bool { + let Some(current) = self.selected else { + return false; + }; + if self.rows.is_empty() { + return false; + } + let next = RowIndex::new( + (current.get() as isize + delta).clamp(0, self.rows.len() as isize - 1) as usize, + ); + let changed = current != next; + self.selected = Some(next); + changed + } + + fn select_edge(&mut self, edge: RowEdge) -> bool { + let Some(next) = edge.index(self.rows.len()) else { + return false; + }; + let changed = self.selected != Some(next); + self.selected = Some(next); + changed + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RowEdge { + First, + Last, +} + +impl RowEdge { + #[must_use] + fn index(self, len: usize) -> Option { + match self { + Self::First => (len > 0).then_some(RowIndex::new(0)), + Self::Last => len.checked_sub(1).map(RowIndex::new), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct PageRows(usize); + +impl Default for PageRows { + fn default() -> Self { + Self(1) + } +} + +impl PageRows { + #[must_use] + fn from_terminal_height(height: u16) -> Self { + Self(usize::from( + height.saturating_sub(TERMINAL_FRAME_ROWS).max(1), + )) + } + + #[must_use] + fn delta(self, direction: PageDirection) -> isize { + let rows = self.0.min(isize::MAX as usize) as isize; + match direction { + PageDirection::Up => -rows, + PageDirection::Down => rows, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PageDirection { + Up, + Down, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +enum SelectionCustody { + #[default] + SystemOwned, + UserOwned, +} + +impl SelectionCustody { + #[must_use] + fn preserves_anchor(self) -> bool { + self == Self::UserOwned + } + + fn seize(&mut self) { + *self = Self::UserOwned; + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct DeMinimis { + threshold: Bytes, +} + +impl DeMinimis { + #[must_use] + fn from_largest_non_root(system_total: Bytes, largest_non_root: Bytes) -> Self { + Self { + threshold: pct(system_total, 1).min(pct(largest_non_root, 3)), + } + } + + #[must_use] + fn folds(self, value: Bytes) -> bool { + self.threshold.0 > 0 && value < self.threshold + } +} + +fn pct(value: Bytes, percent: u64) -> Bytes { + Bytes(((u128::from(value.0) * u128::from(percent)) / 100).min(u128::from(u64::MAX)) as u64) +} + +#[derive(Debug)] +struct FoldPolicy<'a, Key> { + collapsed: &'a BTreeSet, + expanded: &'a BTreeSet, + de_minimis: DeMinimis, +} + +impl FoldPolicy<'_, Key> { + #[must_use] + fn row_fold(&self, key: &Key, depth: usize, has_children: bool, total: Bytes) -> RowFold { + if !has_children { + RowFold::Leaf + } else if self.collapsed.contains(key) { + RowFold::Collapsed + } else if self.expanded.contains(key) { + RowFold::Expanded + } else if depth > 0 && self.de_minimis.folds(total) { + RowFold::Collapsed + } else { + RowFold::Expanded + } + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +enum KeySequence { + #[default] + Root, + G, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SequenceResolution { + Unmatched(KeyEvent), + Pending, + Cancelled, + Command(SequenceCommand), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SequenceCommand { + FirstRow, + LastRow, +} + +impl KeySequence { + fn resolve(&mut self, key: KeyEvent) -> SequenceResolution { + match (*self, plain_char(key)) { + (Self::Root, Some('g')) => { + *self = Self::G; + SequenceResolution::Pending + } + (Self::Root, Some('G')) => SequenceResolution::Command(SequenceCommand::LastRow), + (Self::Root, _) => SequenceResolution::Unmatched(key), + (Self::G, Some('g')) => { + *self = Self::Root; + SequenceResolution::Command(SequenceCommand::FirstRow) + } + (Self::G, _) => { + *self = Self::Root; + SequenceResolution::Cancelled + } + } + } +} + +fn plain_char(key: KeyEvent) -> Option { + if key.modifiers.intersects( + KeyModifiers::CONTROL + | KeyModifiers::ALT + | KeyModifiers::SUPER + | KeyModifiers::HYPER + | KeyModifiers::META, + ) { + return None; + } + match key.code { + KeyCode::Char(character) => Some(character), + KeyCode::Esc => Some('\u{1b}'), + _ => None, + } +} + +pub struct App { + pub tab: Tab, + pub metric: Metric, + pub process_scope: ProcessScope, + focused: bool, + pub show_help: bool, + pub snapshot: Option, + pub last_error: Option, + pub process_scan_started_at: Option, + search: Option, + search_draft: Option, + process_search: SearchSummary, + tmpfs_search: SearchSummary, + shared_search: SearchSummary, + collapsed_processes: BTreeSet, + expanded_processes: BTreeSet, + collapsed_tmpfs: BTreeSet, + expanded_tmpfs: BTreeSet, + process_mapping_cache: BTreeMap, + process_mapping_started_at: Option<(Pid, Instant)>, + shared_scan_started_at: Option, + pub deletions: Vec, + confirmed_deletions: BTreeSet, + pub kill_confirmation: Option, + inventory_warnings: Vec, + tmpfs_refresh_warnings: Vec, + process_warnings: Vec, + pending_process_scan: Option, + tmpfs_selection: SelectionCustody, + process_rows: PaneRows, + tmpfs_rows: PaneRows, + shared_rows: PaneRows, + page_rows: PageRows, + key_sequence: KeySequence, +} + +pub struct KillConfirmation { + pub target: ProcessKillTarget, + opened_at: Instant, +} + +impl KillConfirmation { + #[must_use] + fn new(target: ProcessKillTarget) -> Self { + Self { + target, + opened_at: Instant::now(), + } + } + + #[must_use] + pub fn armed(&self) -> bool { + self.opened_at.elapsed() >= KILL_ARMING_DELAY + } + + #[must_use] + pub fn lock_remaining(&self) -> Duration { + KILL_ARMING_DELAY.saturating_sub(self.opened_at.elapsed()) + } +} + +pub struct ProcessKillTarget { + pub pid: Pid, + pub name: String, + pub command: String, + pidfd: OwnedFd, +} + +impl ProcessKillTarget { + fn capture(process: &ProcessNode) -> std::result::Result { + Ok(Self { + pid: process.pid, + name: process.name.clone(), + command: process.command.clone(), + pidfd: open_pidfd(process.pid)?, + }) + } + + #[must_use] + pub fn cli(&self) -> &str { + if self.command.is_empty() { + &self.name + } else { + &self.command + } + } + + fn send_sigterm(self) -> std::result::Result<(), String> { + pidfd_send_signal(self.pidfd, Signal::TERM) + .map_err(|error| format!("pidfd SIGTERM {}: {error}", self.pid)) + } +} + +pub struct ProcessMappingLedger { + pub pid: Pid, + pub elapsed: Duration, + pub cost: probe::ProcessMappingCost, + pub objects: Vec, + pub mappings_state: LedgerState, +} + +impl ProcessMappingLedger { + fn install(scan: probe::ProcessMappingScan) -> Self { + Self { + pid: scan.pid, + elapsed: scan.elapsed, + cost: scan.cost, + objects: scan.objects, + mappings_state: scan.mappings_state, + } + } +} + +pub struct DeleteTask { + pub path: PathBuf, + mount_point: PathBuf, + result: Receiver, +} + +enum DeleteOutcome { + Deleted, + Failed(String), +} + +#[derive(Clone, Debug)] +enum TombstoneCoverage { + Full, + Mount(PathBuf), +} + +impl TombstoneCoverage { + #[must_use] + fn covers(&self, path: &Path) -> bool { + match self { + Self::Full => true, + Self::Mount(mount_point) => path.starts_with(mount_point), + } + } +} + +impl App { + #[must_use] + pub fn new() -> Self { + Self { + tab: Tab::Processes, + metric: Metric::Pss, + process_scope: ProcessScope::SelfOnly, + focused: true, + show_help: false, + snapshot: None, + last_error: None, + process_scan_started_at: None, + search: None, + search_draft: None, + process_search: SearchSummary::new(Metric::Pss.label()), + tmpfs_search: SearchSummary::new("allocated"), + shared_search: SearchSummary::new(Metric::Pss.label()), + collapsed_processes: BTreeSet::new(), + expanded_processes: BTreeSet::new(), + collapsed_tmpfs: BTreeSet::new(), + expanded_tmpfs: BTreeSet::new(), + process_mapping_cache: BTreeMap::new(), + process_mapping_started_at: None, + shared_scan_started_at: None, + deletions: Vec::new(), + confirmed_deletions: BTreeSet::new(), + kill_confirmation: None, + inventory_warnings: Vec::new(), + tmpfs_refresh_warnings: Vec::new(), + process_warnings: Vec::new(), + pending_process_scan: None, + tmpfs_selection: SelectionCustody::default(), + process_rows: PaneRows::default(), + tmpfs_rows: PaneRows::default(), + shared_rows: PaneRows::default(), + page_rows: PageRows::default(), + key_sequence: KeySequence::default(), + } + } + + pub fn set_terminal_height(&mut self, height: u16) { + self.page_rows = PageRows::from_terminal_height(height); + } + + pub fn start_visible_work(&mut self, commands: &Sender) { + self.request_current_pane(commands); + self.sync_process_scanning(commands); + } + + #[must_use] + pub fn is_focused(&self) -> bool { + self.focused + } + + pub fn set_focused(&mut self, focused: bool, commands: &Sender) { + if self.focused == focused { + return; + } + self.focused = focused; + self.sync_process_scanning(commands); + } + + pub fn apply_worker_event(&mut self, event: WorkerEvent, commands: &Sender) { + match event { + WorkerEvent::InventoryReady(result) => self.apply_inventory_result(result), + WorkerEvent::TmpfsMountReady(result) => self.apply_tmpfs_mount_result(result), + WorkerEvent::ProcessesStarted(started) => self.process_scan_started_at = Some(started), + WorkerEvent::ProcessesReady(result) => self.apply_process_result(result, commands), + WorkerEvent::ProcessMappingsReady(result) => self.apply_process_mappings_result(result), + WorkerEvent::SharedObjectsStarted(started) => { + self.shared_scan_started_at = Some(started); + } + WorkerEvent::SharedObjectsReady(result) => self.apply_shared_objects_result(result), + } + } + + fn apply_inventory_result(&mut self, result: Result>) { + match result { + Ok(snapshot) => { + self.last_error = None; + self.install_inventory_snapshot(*snapshot); + } + Err(error) => self.last_error = Some(format!("{error:#}")), + } + } + + fn apply_tmpfs_mount_result(&mut self, result: Result>) { + match result { + Ok(scan) => { + self.last_error = None; + self.install_tmpfs_mount_scan(*scan); + } + Err(error) => self.last_error = Some(format!("{error:#}")), + } + } + + fn apply_process_result( + &mut self, + result: Result>, + commands: &Sender, + ) { + self.process_scan_started_at = None; + match result { + Ok(scan) => { + self.last_error = None; + self.install_process_scan(*scan); + self.request_selected_process_mappings(commands); + } + Err(error) => self.last_error = Some(format!("{error:#}")), + } + } + + fn apply_process_mappings_result(&mut self, result: Result>) { + match result { + Ok(mut scan) => { + if self + .process_mapping_started_at + .is_some_and(|(pid, _)| pid == scan.pid) + { + self.process_mapping_started_at = None; + } + self.process_warnings.append(&mut scan.warnings); + let ledger = ProcessMappingLedger::install(*scan); + let _ = self.process_mapping_cache.insert(ledger.pid, ledger); + self.last_error = None; + self.rebuild_snapshot_warnings(); + } + Err(error) => { + self.process_mapping_started_at = None; + self.last_error = Some(format!("{error:#}")); + } + } + } + + fn apply_shared_objects_result(&mut self, result: Result>) { + self.shared_scan_started_at = None; + match result { + Ok(mut scan) => { + self.last_error = None; + self.process_warnings.append(&mut scan.warnings); + self.install_shared_objects_scan(*scan); + } + Err(error) => self.last_error = Some(format!("{error:#}")), + } + } + + fn install_inventory_snapshot(&mut self, mut snapshot: Snapshot) { + self.inventory_warnings = std::mem::take(&mut snapshot.warnings); + self.tmpfs_refresh_warnings.clear(); + let tmpfs_fresh = !snapshot.tmpfs_mounts.is_empty(); + if let Some(current) = self.snapshot.take() { + snapshot.process_tree = current.process_tree; + snapshot.shared_objects = current.shared_objects; + if snapshot.tmpfs_mounts.is_empty() { + snapshot.tmpfs_mounts = current.tmpfs_mounts; + } + } + if tmpfs_fresh { + self.reconcile_confirmed_deletions(&snapshot.tmpfs_mounts, TombstoneCoverage::Full); + } + self.prune_tmpfs_tombstones(&mut snapshot.tmpfs_mounts); + probe::rebuild_snapshot_derived(&mut snapshot); + self.snapshot = Some(snapshot); + self.rebuild_snapshot_warnings(); + if tmpfs_fresh { + self.rebuild_tmpfs_rows(); + } + + if let Some(scan) = self.pending_process_scan.take() { + self.install_process_scan(scan); + } + self.rebuild_shared_rows(); + } + + fn install_process_scan(&mut self, mut scan: probe::ProcessScan) { + self.process_warnings = std::mem::take(&mut scan.warnings); + if self.snapshot.is_none() { + self.snapshot = Some(empty_snapshot(scan.meminfo.clone())); + } + let Some(snapshot) = self.snapshot.as_mut() else { + self.pending_process_scan = Some(scan); + return; + }; + scan.install(snapshot); + self.rebuild_snapshot_warnings(); + self.rebuild_process_rows(); + self.retain_live_process_mapping_cache(); + } + + fn install_shared_objects_scan(&mut self, scan: probe::SharedObjectsScan) { + if self.snapshot.is_none() { + self.snapshot = Some(empty_snapshot(scan.meminfo.clone())); + } + let Some(snapshot) = self.snapshot.as_mut() else { + return; + }; + snapshot.captured_at = scan.captured_at; + snapshot.elapsed = scan.elapsed; + snapshot.meminfo = scan.meminfo; + snapshot.shared_objects = scan.shared_objects; + probe::rebuild_snapshot_derived(snapshot); + self.rebuild_snapshot_warnings(); + self.rebuild_shared_rows(); + } + + fn install_tmpfs_mount_scan(&mut self, mut scan: probe::TmpfsMountScan) { + self.tmpfs_refresh_warnings = std::mem::take(&mut scan.warnings); + self.reconcile_confirmed_deletions( + std::slice::from_ref(&scan.mount), + TombstoneCoverage::Mount(scan.mount.mount_point.clone()), + ); + self.prune_tmpfs_tombstones(std::slice::from_mut(&mut scan.mount)); + if self.snapshot.is_none() { + self.snapshot = Some(empty_snapshot(Meminfo::default())); + } + let Some(snapshot) = self.snapshot.as_mut() else { + return; + }; + + snapshot.captured_at = scan.captured_at; + snapshot.elapsed = scan.elapsed; + if let Some(slot) = snapshot + .tmpfs_mounts + .iter_mut() + .find(|mount| mount.mount_point == scan.mount.mount_point) + { + *slot = scan.mount; + } else { + snapshot.tmpfs_mounts.push(scan.mount); + } + snapshot + .tmpfs_mounts + .sort_by_key(|mount| std::cmp::Reverse(mount.root.allocated)); + probe::rebuild_snapshot_derived(snapshot); + self.rebuild_snapshot_warnings(); + self.rebuild_tmpfs_rows(); + } + + fn rebuild_snapshot_warnings(&mut self) { + let Some(snapshot) = self.snapshot.as_mut() else { + return; + }; + let mut warnings = Vec::with_capacity( + self.inventory_warnings.len() + + self.tmpfs_refresh_warnings.len() + + self.process_warnings.len(), + ); + warnings.extend(self.inventory_warnings.iter().cloned()); + warnings.extend(self.tmpfs_refresh_warnings.iter().cloned()); + warnings.extend(self.process_warnings.iter().cloned()); + snapshot.warnings = warnings; + } + + fn tmpfs_tombstones(&self) -> BTreeSet { + let mut tombstones = self.confirmed_deletions.clone(); + tombstones.extend(self.deletions.iter().map(|task| task.path.clone())); + tombstones + } + + fn prune_tmpfs_tombstones(&self, mounts: &mut [TmpfsMount]) { + let tombstones = self.tmpfs_tombstones(); + prune_tmpfs_tombstones(mounts, &tombstones); + } + + fn prune_tmpfs_path(&mut self, path: &Path) { + let mut tombstones = self.tmpfs_tombstones(); + let _ = tombstones.insert(path.to_path_buf()); + let Some(snapshot) = self.snapshot.as_mut() else { + return; + }; + prune_tmpfs_tombstones(&mut snapshot.tmpfs_mounts, &tombstones); + probe::rebuild_snapshot_derived(snapshot); + self.rebuild_tmpfs_rows(); + } + + fn reconcile_confirmed_deletions( + &mut self, + mounts: &[TmpfsMount], + coverage: TombstoneCoverage, + ) { + self.confirmed_deletions.retain(|path| { + if !coverage.covers(path) { + return true; + } + mounts + .iter() + .filter(|mount| path.starts_with(&mount.mount_point)) + .any(|mount| tmpfs_tree_contains_path(&mount.root, path)) + }); + } + + pub fn poll_deletion(&mut self, commands: &Sender) -> bool { + let mut changed = false; + let mut index = 0; + while index < self.deletions.len() { + match self.deletions[index].result.try_recv() { + Ok(DeleteOutcome::Deleted) => { + let task = self.deletions.remove(index); + let _ = self.confirmed_deletions.insert(task.path); + self.last_error = None; + let _ = commands.send(WorkerCommand::RefreshTmpfsMount(task.mount_point)); + changed = true; + } + Ok(DeleteOutcome::Failed(error)) => { + let task = self.deletions.remove(index); + let _ = self.confirmed_deletions.remove(&task.path); + self.last_error = Some(error); + let _ = commands.send(WorkerCommand::RefreshTmpfsMount(task.mount_point)); + changed = true; + } + Err(TryRecvError::Empty) => index += 1, + Err(TryRecvError::Disconnected) => { + let task = self.deletions.remove(index); + let _ = self.confirmed_deletions.remove(&task.path); + self.last_error = Some(format!( + "delete task disconnected for {}", + task.path.display() + )); + let _ = commands.send(WorkerCommand::RefreshTmpfsMount(task.mount_point)); + changed = true; + } + } + } + changed + } + + #[must_use] + pub fn needs_periodic_redraw(&self) -> bool { + self.focused + && (self.kill_confirmation.is_some() + || self.process_scan_started_at.is_some() + || self.process_mapping_started_at.is_some() + || self.shared_scan_started_at.is_some()) + } + + fn rebuild_filterable_rows(&mut self) { + self.rebuild_process_rows(); + self.rebuild_tmpfs_rows(); + self.rebuild_shared_rows(); + } + + #[must_use] + pub fn tab_labels() -> Vec { + Tab::ALL + .iter() + .enumerate() + .map(|(index, tab)| format!("{} {}", index + 1, tab.title())) + .collect() + } + + #[must_use] + pub fn current_time_label(&self) -> String { + let Some(snapshot) = self.snapshot.as_ref() else { + return "loading".to_string(); + }; + match snapshot.captured_at.duration_since(SystemTime::UNIX_EPOCH) { + Ok(since_epoch) => format!("captured {}", since_epoch.as_secs()), + Err(_) => "captured".to_string(), + } + } + + #[must_use] + pub fn hotkey_sections(&self) -> HotkeySections { + HotkeySections { + global: crate::nav::global_hotkeys(), + pane_title: self.tab.title(), + pane: self.tab.hotkeys(), + } + } + + fn rebuild_process_rows(&mut self) { + let (rows, summary) = self.snapshot.as_ref().map_or_else( + || (Vec::new(), SearchSummary::new(self.metric.label())), + |snapshot| { + build_process_rows( + snapshot, + self.metric, + self.process_scope, + &self.collapsed_processes, + &self.expanded_processes, + self.search.as_ref(), + ) + }, + ); + self.process_search = summary; + self.process_rows.install(rows); + } + + fn rebuild_tmpfs_rows(&mut self) { + let (rows, summary) = self.snapshot.as_ref().map_or_else( + || (Vec::new(), SearchSummary::new("allocated")), + |snapshot| { + build_tmpfs_rows( + snapshot, + self.process_scope, + &self.collapsed_tmpfs, + &self.expanded_tmpfs, + self.search.as_ref(), + ) + }, + ); + self.tmpfs_search = summary; + if self.tmpfs_selection.preserves_anchor() { + self.tmpfs_rows.install(rows); + } else { + self.tmpfs_rows.install_pinned_to_top(rows); + } + } + + fn rebuild_shared_rows(&mut self) { + let (rows, summary) = self.snapshot.as_ref().map_or_else( + || (Vec::new(), SearchSummary::new(self.metric.label())), + |snapshot| build_shared_rows(snapshot, self.metric, self.search.as_ref()), + ); + self.shared_search = summary; + self.shared_rows.install(rows); + } + + #[must_use] + pub fn search_pattern(&self) -> Option<&str> { + self.search.as_ref().map(Search::pattern) + } + + #[must_use] + pub fn search_draft(&self) -> Option<&SearchDraft> { + self.search_draft.as_ref() + } + + #[must_use] + pub fn search_summary(&self) -> Option<&SearchSummary> { + let _active = self.search.as_ref()?; + Some(match self.tab { + Tab::Overview => return None, + Tab::Processes => &self.process_search, + Tab::Tmpfs => &self.tmpfs_search, + Tab::Shared => &self.shared_search, + }) + } + + #[must_use] + pub fn search_scope_label(&self) -> &'static str { + self.process_scope.label() + } + + #[must_use] + pub fn deletion_count(&self) -> usize { + self.deletions.len() + } + + #[must_use] + pub fn process_rows(&self) -> &[FlatProcessRow] { + self.process_rows.rows() + } + + #[must_use] + pub fn tmpfs_rows(&self) -> &[FlatTmpfsRow] { + self.tmpfs_rows.rows() + } + + #[must_use] + pub fn shared_rows(&self) -> &[FlatSharedRow] { + self.shared_rows.rows() + } + + #[must_use] + pub fn selected_process_row(&self) -> usize { + self.process_rows.selected_index() + } + + #[must_use] + pub fn selected_tmpfs_row(&self) -> usize { + self.tmpfs_rows.selected_index() + } + + #[must_use] + pub fn selected_shared_row(&self) -> usize { + self.shared_rows.selected_index() + } + + #[must_use] + pub fn selected_tmpfs_entry(&self) -> Option<&FlatTmpfsRow> { + self.tmpfs_rows.selected() + } + + #[must_use] + pub fn selected_process(&self) -> Option<&ProcessNode> { + let row = self.process_rows.selected()?; + let snapshot = self.snapshot.as_ref()?; + snapshot.process_tree.nodes.get(row.index) + } + + #[must_use] + pub fn selected_process_objects(&self) -> &[ObjectUsage] { + let Some(process) = self.selected_process() else { + return &[]; + }; + self.process_mapping_cache + .get(&process.pid) + .map_or(&[], |ledger| ledger.objects.as_slice()) + } + + #[must_use] + pub fn selected_process_mapping_status(&self) -> &'static str { + let Some(process) = self.selected_process() else { + return "none"; + }; + if self + .process_mapping_started_at + .is_some_and(|(pid, _)| pid == process.pid) + { + return "loading"; + } + self.process_mapping_cache + .get(&process.pid) + .map_or(process.mappings_state.label(), |ledger| { + ledger.mappings_state.label() + }) + } + + #[must_use] + pub fn selected_process_mapping_loading(&self) -> Option<(Pid, Duration)> { + let process = self.selected_process()?; + let (pid, started_at) = self.process_mapping_started_at?; + (pid == process.pid).then_some((pid, started_at.elapsed())) + } + + #[must_use] + pub fn selected_process_mapping_scan_label(&self) -> String { + if let Some((pid, elapsed)) = self.selected_process_mapping_loading() { + return format!("loading pid {pid}: {} ms", elapsed.as_millis()); + } + let Some(process) = self.selected_process() else { + return "none".to_string(); + }; + self.process_mapping_cache.get(&process.pid).map_or_else( + || "not loaded".to_string(), + |ledger| { + format!( + "{} ms (mount {} read {} parse {})", + ledger.elapsed.as_millis(), + ledger.cost.mount_index.as_millis(), + ledger.cost.read.as_millis(), + ledger.cost.parse.as_millis() + ) + }, + ) + } + + #[must_use] + pub fn process_mapping_started_at(&self) -> Option<(Pid, Instant)> { + self.process_mapping_started_at + } + + #[must_use] + pub fn shared_scan_started_at(&self) -> Option { + self.shared_scan_started_at + } + + #[must_use] + pub fn selected_tmpfs_mount(&self) -> Option<&TmpfsMount> { + let snapshot = self.snapshot.as_ref()?; + let row = self.tmpfs_rows.selected()?; + snapshot.tmpfs_mounts.get(row.mount_index) + } + + #[must_use] + pub fn selected_shared_object(&self) -> Option<&SharedObject> { + let row = self.shared_rows.selected()?; + self.snapshot.as_ref()?.shared_objects.get(row.index) + } + + pub fn handle_key(&mut self, key: KeyEvent, commands: &Sender) -> bool { + if self.kill_confirmation.is_some() { + return self.handle_kill_confirmation_key(key, commands); + } + if self.search_draft.is_some() { + return self.handle_search_key(key); + } + if self.show_help { + return self.handle_help_key(key); + } + if matches!(key.code, KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL)) { + return true; + } + + match self.key_sequence.resolve(key) { + SequenceResolution::Unmatched(key) => self.handle_single_key(key, commands), + SequenceResolution::Pending | SequenceResolution::Cancelled => false, + SequenceResolution::Command(command) => { + self.handle_sequence_command(command, commands); + false + } + } + } + + fn handle_single_key(&mut self, key: KeyEvent, commands: &Sender) -> bool { + match key.code { + KeyCode::Char('q') => return true, + KeyCode::Esc => {} + KeyCode::Char('?') => self.show_help = !self.show_help, + KeyCode::Char('/') => self.open_search(), + KeyCode::Char('f') => self.clear_search(), + KeyCode::Tab => self.select_tab(self.tab.next(), commands), + KeyCode::BackTab => self.select_tab(self.tab.previous(), commands), + KeyCode::Char('1') => self.select_tab(Tab::Overview, commands), + KeyCode::Char('2') => self.select_tab(Tab::Processes, commands), + KeyCode::Char('3') => self.select_tab(Tab::Tmpfs, commands), + KeyCode::Char('4') => self.select_tab(Tab::Shared, commands), + KeyCode::Char('s') => { + self.metric = self.metric.next(); + self.rebuild_process_rows(); + self.rebuild_shared_rows(); + } + KeyCode::Char('m') => { + self.process_scope = self.process_scope.next(); + self.rebuild_filterable_rows(); + } + KeyCode::Char('d') => self.delete_current_tmpfs_entry(), + KeyCode::Char('K') => self.arm_process_kill(), + KeyCode::Char('r') => self.refresh_current_pane(commands), + KeyCode::Down | KeyCode::Char('j') => { + let _ = self.move_selection_and_request_mappings(1, commands); + } + KeyCode::Up | KeyCode::Char('k') => { + let _ = self.move_selection_and_request_mappings(-1, commands); + } + KeyCode::PageDown => { + let _ = self.page_selection_and_request_mappings(PageDirection::Down, commands); + } + KeyCode::PageUp => { + let _ = self.page_selection_and_request_mappings(PageDirection::Up, commands); + } + KeyCode::Left | KeyCode::Char('h') => self.collapse_current(), + KeyCode::Right | KeyCode::Char('l') => self.expand_current(), + KeyCode::Enter => self.toggle_current(), + _ => {} + } + false + } + + fn handle_sequence_command( + &mut self, + command: SequenceCommand, + commands: &Sender, + ) { + match command { + SequenceCommand::FirstRow => { + let _ = self.select_edge_and_request_mappings(RowEdge::First, commands); + } + SequenceCommand::LastRow => { + let _ = self.select_edge_and_request_mappings(RowEdge::Last, commands); + } + } + } + + pub fn handle_mouse(&mut self, mouse: MouseEvent, commands: &Sender) -> bool { + if self.kill_confirmation.is_some() || self.show_help { + return false; + } + self.key_sequence = KeySequence::Root; + + match mouse.kind { + MouseEventKind::ScrollDown => self.move_selection_and_request_mappings(1, commands), + MouseEventKind::ScrollUp => self.move_selection_and_request_mappings(-1, commands), + MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight => false, + MouseEventKind::Down(_) + | MouseEventKind::Up(_) + | MouseEventKind::Drag(_) + | MouseEventKind::Moved => false, + } + } + + fn select_tab(&mut self, tab: Tab, commands: &Sender) { + self.tab = tab; + if tab == Tab::Tmpfs { + self.seize_tmpfs_selection(); + } + self.request_current_pane(commands); + self.sync_process_scanning(commands); + } + + fn sync_process_scanning(&self, commands: &Sender) { + let _ = commands.send(WorkerCommand::SetProcessScanning( + self.focused && self.tab.drives_process_scans(), + )); + } + + fn request_current_pane(&mut self, commands: &Sender) { + match self.tab { + Tab::Overview => { + let _ = commands.send(WorkerCommand::RefreshInventory); + } + Tab::Processes => self.request_selected_process_mappings(commands), + Tab::Tmpfs => { + let _ = commands.send(WorkerCommand::RefreshTmpfsMounts); + } + Tab::Shared => { + let _ = commands.send(WorkerCommand::RefreshSharedObjects); + } + } + } + + fn request_selected_process_mappings(&mut self, commands: &Sender) { + if self.tab != Tab::Processes { + return; + } + let Some(process) = self.selected_process() else { + return; + }; + if self.process_mapping_cache.contains_key(&process.pid) + || self + .process_mapping_started_at + .is_some_and(|(pid, _)| pid == process.pid) + { + return; + } + let pid = process.pid; + self.process_mapping_started_at = Some((pid, Instant::now())); + let _ = commands.send(WorkerCommand::RefreshProcessMappings(pid)); + } + + fn retain_live_process_mapping_cache(&mut self) { + let Some(snapshot) = self.snapshot.as_ref() else { + self.process_mapping_cache.clear(); + self.process_mapping_started_at = None; + return; + }; + let live = snapshot + .process_tree + .nodes + .iter() + .map(|node| node.pid) + .collect::>(); + self.process_mapping_cache + .retain(|pid, _ledger| live.contains(pid)); + if self + .process_mapping_started_at + .is_some_and(|(pid, _)| !live.contains(&pid)) + { + self.process_mapping_started_at = None; + } + } + + fn refresh_current_pane(&mut self, commands: &Sender) { + let command = match self.tab { + Tab::Overview => WorkerCommand::RefreshInventory, + Tab::Processes => WorkerCommand::RefreshProcesses, + Tab::Tmpfs => self + .selected_tmpfs_mount() + .map(|mount| WorkerCommand::RefreshTmpfsMount(mount.mount_point.clone())) + .unwrap_or(WorkerCommand::RefreshTmpfsMounts), + Tab::Shared => WorkerCommand::RefreshSharedObjects, + }; + let _ = commands.send(command); + } + + fn open_search(&mut self) { + self.key_sequence = KeySequence::Root; + self.search_draft = Some(SearchDraft::new(self.search.as_ref())); + } + + fn clear_search(&mut self) { + if self.search.take().is_some() { + self.rebuild_filterable_rows(); + } + } + + fn handle_search_key(&mut self, key: KeyEvent) -> bool { + match key.code { + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => true, + KeyCode::Esc => { + self.search_draft = None; + false + } + KeyCode::Enter => { + self.commit_search(); + false + } + KeyCode::Backspace => { + if let Some(draft) = self.search_draft.as_mut() { + draft.backspace(); + } + false + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + if let Some(draft) = self.search_draft.as_mut() { + draft.clear(); + } + false + } + KeyCode::Char(character) if plain_char(key).is_some() => { + if let Some(draft) = self.search_draft.as_mut() { + draft.push(character); + } + false + } + _ => false, + } + } + + fn commit_search(&mut self) { + let Some(draft) = self.search_draft.take() else { + return; + }; + let input = draft.into_input(); + match Search::compile(input.clone()) { + Ok(search) => { + self.search = search; + self.rebuild_filterable_rows(); + } + Err(error) => { + let mut draft = SearchDraft::from_input(input); + draft.fail(error); + self.search_draft = Some(draft); + } + } + } + + fn handle_help_key(&mut self, key: KeyEvent) -> bool { + match key.code { + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => true, + KeyCode::Esc | KeyCode::Char('?') => { + self.show_help = false; + false + } + _ => false, + } + } + + fn handle_kill_confirmation_key( + &mut self, + key: KeyEvent, + commands: &Sender, + ) -> bool { + match key.code { + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => true, + KeyCode::Esc | KeyCode::Char('n') => { + self.kill_confirmation = None; + false + } + KeyCode::Char('y') + if self + .kill_confirmation + .as_ref() + .is_some_and(KillConfirmation::armed) => + { + self.confirm_process_kill(commands); + false + } + _ => false, + } + } + + fn move_selection(&mut self, delta: isize) -> bool { + match self.tab { + Tab::Overview => false, + Tab::Processes => self.process_rows.move_by(delta), + Tab::Tmpfs => self.tmpfs_rows.move_by(delta), + Tab::Shared => self.shared_rows.move_by(delta), + } + } + + fn seize_tmpfs_selection(&mut self) { + if self.tmpfs_selection.preserves_anchor() { + return; + } + let _ = self.tmpfs_rows.select_edge(RowEdge::First); + self.tmpfs_selection.seize(); + } + + fn page_selection(&mut self, direction: PageDirection) -> bool { + self.move_selection(self.page_rows.delta(direction)) + } + + fn move_selection_and_request_mappings( + &mut self, + delta: isize, + commands: &Sender, + ) -> bool { + let changed = self.move_selection(delta); + self.request_selected_process_mappings_after(changed, commands) + } + + fn page_selection_and_request_mappings( + &mut self, + direction: PageDirection, + commands: &Sender, + ) -> bool { + let changed = self.page_selection(direction); + self.request_selected_process_mappings_after(changed, commands) + } + + fn select_edge_and_request_mappings( + &mut self, + edge: RowEdge, + commands: &Sender, + ) -> bool { + let changed = self.select_edge(edge); + self.request_selected_process_mappings_after(changed, commands) + } + + fn request_selected_process_mappings_after( + &mut self, + changed: bool, + commands: &Sender, + ) -> bool { + if changed { + self.request_selected_process_mappings(commands); + } + changed + } + + fn select_edge(&mut self, edge: RowEdge) -> bool { + match self.tab { + Tab::Overview => false, + Tab::Processes => self.process_rows.select_edge(edge), + Tab::Tmpfs => self.tmpfs_rows.select_edge(edge), + Tab::Shared => self.shared_rows.select_edge(edge), + } + } + + fn collapse_current(&mut self) { + match self.tab { + Tab::Processes => { + let Some((pid, fold)) = self.process_rows.selected().map(|row| (row.pid, row.fold)) + else { + return; + }; + if fold != RowFold::Leaf { + let _ = self.expanded_processes.remove(&pid); + let _ = self.collapsed_processes.insert(pid); + self.rebuild_process_rows(); + } + } + Tab::Tmpfs => { + let Some((path, fold)) = self + .tmpfs_rows + .selected() + .map(|row| (row.path.clone(), row.fold)) + else { + return; + }; + if fold != RowFold::Leaf { + let _ = self.expanded_tmpfs.remove(&path); + let _ = self.collapsed_tmpfs.insert(path); + self.rebuild_tmpfs_rows(); + } + } + Tab::Overview | Tab::Shared => {} + } + } + + fn expand_current(&mut self) { + match self.tab { + Tab::Processes => { + if let Some((pid, fold)) = + self.process_rows.selected().map(|row| (row.pid, row.fold)) + && fold != RowFold::Leaf + { + let _ = self.collapsed_processes.remove(&pid); + let _ = self.expanded_processes.insert(pid); + self.rebuild_process_rows(); + } + } + Tab::Tmpfs => { + if let Some((path, fold)) = self + .tmpfs_rows + .selected() + .map(|row| (row.path.clone(), row.fold)) + && fold != RowFold::Leaf + { + let _ = self.collapsed_tmpfs.remove(&path); + let _ = self.expanded_tmpfs.insert(path); + self.rebuild_tmpfs_rows(); + } + } + Tab::Overview | Tab::Shared => {} + } + } + + fn toggle_current(&mut self) { + match self.tab { + Tab::Processes => { + let Some((pid, fold)) = self.process_rows.selected().map(|row| (row.pid, row.fold)) + else { + return; + }; + match fold { + RowFold::Leaf => return, + RowFold::Collapsed => { + let _ = self.collapsed_processes.remove(&pid); + let _ = self.expanded_processes.insert(pid); + } + RowFold::Expanded => { + let _ = self.expanded_processes.remove(&pid); + let _ = self.collapsed_processes.insert(pid); + } + } + self.rebuild_process_rows(); + } + Tab::Tmpfs => { + let Some((path, fold)) = self + .tmpfs_rows + .selected() + .map(|row| (row.path.clone(), row.fold)) + else { + return; + }; + match fold { + RowFold::Leaf => return, + RowFold::Collapsed => { + let _ = self.collapsed_tmpfs.remove(&path); + let _ = self.expanded_tmpfs.insert(path); + } + RowFold::Expanded => { + let _ = self.expanded_tmpfs.remove(&path); + let _ = self.collapsed_tmpfs.insert(path); + } + } + self.rebuild_tmpfs_rows(); + } + Tab::Overview | Tab::Shared => {} + } + } + + fn delete_current_tmpfs_entry(&mut self) { + if self.tab != Tab::Tmpfs { + return; + } + let Some((path, kind)) = self + .tmpfs_rows + .selected() + .map(|row| (row.path.clone(), row.kind)) + else { + return; + }; + let Some(mount_point) = self + .selected_tmpfs_mount() + .map(|mount| mount.mount_point.clone()) + else { + return; + }; + + if kind == TmpfsNodeKind::Mount { + self.last_error = Some(format!( + "refusing to delete tmpfs mount root {}", + path.display() + )); + return; + } + + let Some(successor) = self.tmpfs_rows.selected_slot() else { + return; + }; + let (sender, result) = mpsc::channel(); + let task_path = path.clone(); + let _handle = thread::spawn(move || { + let outcome = delete_tmpfs_entry(&task_path, kind) + .map_err(|error| format!("delete {}: {error}", task_path.display())) + .map_or_else(DeleteOutcome::Failed, |()| DeleteOutcome::Deleted); + let _ = sender.send(outcome); + }); + + self.last_error = None; + let _ = self.collapsed_tmpfs.remove(&path); + let _ = self.expanded_tmpfs.remove(&path); + self.deletions.push(DeleteTask { + path: path.clone(), + mount_point, + result, + }); + self.prune_tmpfs_path(&path); + let _ = self.tmpfs_rows.select_clamped(successor); + } + + fn arm_process_kill(&mut self) { + if self.tab != Tab::Processes { + return; + } + let Some(process) = self.selected_process() else { + return; + }; + + match ProcessKillTarget::capture(process) { + Ok(target) => { + self.last_error = None; + self.kill_confirmation = Some(KillConfirmation::new(target)); + } + Err(error) => self.last_error = Some(error), + } + } + + fn confirm_process_kill(&mut self, commands: &Sender) { + let Some(confirmation) = self.kill_confirmation.take() else { + return; + }; + match confirmation.target.send_sigterm() { + Ok(()) => { + self.last_error = None; + let _ = commands.send(WorkerCommand::RefreshProcesses); + } + Err(error) => self.last_error = Some(error), + } + } +} + +fn open_pidfd(pid: Pid) -> std::result::Result { + let Some(kernel_pid) = KernelPid::from_raw(pid.0) else { + return Err(format!("cannot arm SIGTERM for invalid pid {pid}")); + }; + pidfd_open(kernel_pid, PidfdFlags::empty()) + .map_err(|error| format!("pidfd_open {pid}: {error}")) +} + +fn delete_tmpfs_entry(path: &Path, kind: TmpfsNodeKind) -> std::io::Result<()> { + let result = match kind { + TmpfsNodeKind::Directory => fs::remove_dir_all(path), + TmpfsNodeKind::Mount => Ok(()), + TmpfsNodeKind::File + | TmpfsNodeKind::Symlink + | TmpfsNodeKind::Socket + | TmpfsNodeKind::Fifo + | TmpfsNodeKind::CharDevice + | TmpfsNodeKind::BlockDevice + | TmpfsNodeKind::Other => fs::remove_file(path), + }; + match result { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } +} + +#[derive(Clone, Copy, Debug, Default)] +struct TmpfsUsage { + allocated: Bytes, + logical: Bytes, +} + +impl TmpfsUsage { + #[must_use] + fn from_node(node: &TmpfsNode) -> Self { + Self { + allocated: node.allocated, + logical: node.logical, + } + } + + fn absorb(&mut self, usage: Self) { + self.allocated += usage.allocated; + self.logical += usage.logical; + } +} + +fn prune_tmpfs_tombstones(mounts: &mut [TmpfsMount], tombstones: &BTreeSet) { + if tombstones.is_empty() { + return; + } + for mount in mounts { + let removed = prune_tmpfs_node_children(&mut mount.root, tombstones); + mount.root.allocated -= removed.allocated; + mount.root.logical -= removed.logical; + } +} + +fn prune_tmpfs_node_children(node: &mut TmpfsNode, tombstones: &BTreeSet) -> TmpfsUsage { + let mut removed = TmpfsUsage::default(); + node.children.retain_mut(|child| { + if tmpfs_path_is_tombstoned(&child.path, tombstones) { + removed.absorb(TmpfsUsage::from_node(child)); + return false; + } + let child_removed = prune_tmpfs_node_children(child, tombstones); + child.allocated -= child_removed.allocated; + child.logical -= child_removed.logical; + removed.absorb(child_removed); + true + }); + removed +} + +fn tmpfs_path_is_tombstoned(path: &Path, tombstones: &BTreeSet) -> bool { + tombstones + .iter() + .any(|tombstone| path == tombstone || path.starts_with(tombstone)) +} + +fn tmpfs_tree_contains_path(node: &TmpfsNode, path: &Path) -> bool { + node.path == path + || (path.starts_with(&node.path) + && node + .children + .iter() + .any(|child| tmpfs_tree_contains_path(child, path))) +} + +fn empty_snapshot(meminfo: Meminfo) -> Snapshot { + let mut snapshot = Snapshot { + captured_at: SystemTime::now(), + elapsed: Duration::ZERO, + meminfo, + overview: crate::model::Overview::default(), + process_tree: crate::model::ProcessTree::default(), + shared_objects: Vec::new(), + sysv_segments: Vec::new(), + tmpfs_mounts: Vec::new(), + warnings: Vec::new(), + }; + probe::rebuild_snapshot_derived(&mut snapshot); + snapshot +} + +#[cfg(test)] +mod tests; diff --git a/crates/memview/src/app/rows.rs b/crates/memview/src/app/rows.rs new file mode 100644 index 0000000..d6d904d --- /dev/null +++ b/crates/memview/src/app/rows.rs @@ -0,0 +1,484 @@ +use super::*; + +pub(super) fn build_process_rows( + snapshot: &Snapshot, + metric: Metric, + scope: ProcessScope, + collapsed: &BTreeSet, + expanded: &BTreeSet, + search: Option<&Search>, +) -> (Vec, SearchSummary) { + let mut summary = SearchSummary::new(metric.label()); + let mut rows = Vec::new(); + let roots = sorted_process_roots(snapshot, metric, scope); + if let Some(search) = search { + match scope { + ProcessScope::SelfOnly => { + for root in roots { + push_process_search_self( + root, + 0, + snapshot, + metric, + search, + &mut rows, + &mut summary, + ); + } + } + ProcessScope::SelfAndChildren => { + for root in roots { + let _ = push_process_search_tree( + root, + 0, + snapshot, + metric, + search, + false, + &mut rows, + &mut summary, + ); + } + } + } + return (rows, summary); + } + + let policy = FoldPolicy { + collapsed, + expanded, + de_minimis: DeMinimis::from_largest_non_root( + snapshot.meminfo.get("MemTotal"), + largest_process_non_root_subtree(snapshot, metric), + ), + }; + for root in roots { + push_process_rows(root, 0, snapshot, metric, scope, &policy, &mut rows); + } + (rows, summary) +} + +pub(super) fn build_tmpfs_rows( + snapshot: &Snapshot, + scope: ProcessScope, + collapsed: &BTreeSet, + expanded: &BTreeSet, + search: Option<&Search>, +) -> (Vec, SearchSummary) { + let mut summary = SearchSummary::new("allocated"); + let mut rows = Vec::new(); + if let Some(search) = search { + match scope { + ProcessScope::SelfOnly => { + for (mount_index, mount) in snapshot.tmpfs_mounts.iter().enumerate() { + push_tmpfs_search_self( + mount_index, + &mount.root, + 0, + search, + &mut rows, + &mut summary, + ); + } + } + ProcessScope::SelfAndChildren => { + for (mount_index, mount) in snapshot.tmpfs_mounts.iter().enumerate() { + let _ = push_tmpfs_search_tree( + mount_index, + &mount.root, + 0, + search, + false, + &mut rows, + &mut summary, + ); + } + } + } + return (rows, summary); + } + + let policy = FoldPolicy { + collapsed, + expanded, + de_minimis: DeMinimis::from_largest_non_root( + snapshot.meminfo.get("MemTotal"), + largest_tmpfs_non_root_subtree(snapshot), + ), + }; + for (mount_index, mount) in snapshot.tmpfs_mounts.iter().enumerate() { + push_tmpfs_rows(mount_index, &mount.root, 0, &policy, &mut rows); + } + (rows, summary) +} + +pub(super) fn build_shared_rows( + snapshot: &Snapshot, + metric: Metric, + search: Option<&Search>, +) -> (Vec, SearchSummary) { + let mut summary = SearchSummary::new(metric.label()); + let rows = snapshot + .shared_objects + .iter() + .enumerate() + .filter_map(|(index, object)| { + let direct = search.is_none_or(|search| shared_matches(search, object)); + if !direct { + return None; + } + if search.is_some() { + summary.strike(object.rollup.metric(metric)); + } + Some(FlatSharedRow { + index, + key: (object.kind, object.label.clone()), + search: search.map_or(SearchRole::Ordinary, |_| SearchRole::Match), + }) + }) + .collect(); + (rows, summary) +} + +fn sorted_process_roots(snapshot: &Snapshot, metric: Metric, scope: ProcessScope) -> Vec { + let mut roots = snapshot.process_tree.roots.clone(); + roots.sort_by(|lhs, rhs| { + process_cmp( + &snapshot.process_tree.nodes[*lhs], + &snapshot.process_tree.nodes[*rhs], + metric, + scope, + ) + }); + roots +} + +fn largest_process_non_root_subtree(snapshot: &Snapshot, metric: Metric) -> Bytes { + snapshot + .process_tree + .roots + .iter() + .flat_map(|root| snapshot.process_tree.nodes[*root].children.iter().copied()) + .map(|child| largest_process_subtree(child, snapshot, metric)) + .max() + .unwrap_or(Bytes::ZERO) +} + +fn largest_process_subtree(index: usize, snapshot: &Snapshot, metric: Metric) -> Bytes { + let node = &snapshot.process_tree.nodes[index]; + let child_max = node + .children + .iter() + .copied() + .map(|child| largest_process_subtree(child, snapshot, metric)) + .max() + .unwrap_or(Bytes::ZERO); + if node.children.is_empty() { + child_max + } else { + child_max.max(node.subtree.metric(metric)) + } +} + +fn push_process_rows( + index: usize, + depth: usize, + snapshot: &Snapshot, + metric: Metric, + scope: ProcessScope, + policy: &FoldPolicy<'_, Pid>, + rows: &mut Vec, +) { + let node = &snapshot.process_tree.nodes[index]; + let fold = policy.row_fold( + &node.pid, + depth, + !node.children.is_empty(), + node.subtree.metric(metric), + ); + rows.push(FlatProcessRow { + index, + pid: node.pid, + depth, + fold, + search: SearchRole::Ordinary, + }); + if fold.is_collapsed() { + return; + } + + for child in sorted_process_children(node, snapshot, metric, scope) { + push_process_rows(child, depth + 1, snapshot, metric, scope, policy, rows); + } +} + +fn sorted_process_children( + node: &ProcessNode, + snapshot: &Snapshot, + metric: Metric, + scope: ProcessScope, +) -> Vec { + let mut children = node.children.clone(); + children.sort_by(|lhs, rhs| { + process_cmp( + &snapshot.process_tree.nodes[*lhs], + &snapshot.process_tree.nodes[*rhs], + metric, + scope, + ) + }); + children +} + +fn push_process_search_self( + index: usize, + depth: usize, + snapshot: &Snapshot, + metric: Metric, + search: &Search, + rows: &mut Vec, + summary: &mut SearchSummary, +) { + let node = &snapshot.process_tree.nodes[index]; + if process_matches(search, node) { + summary.strike(node.rollup.metric(metric)); + rows.push(FlatProcessRow { + index, + pid: node.pid, + depth, + fold: RowFold::Leaf, + search: SearchRole::Match, + }); + } + for child in sorted_process_children(node, snapshot, metric, ProcessScope::SelfOnly) { + push_process_search_self(child, depth + 1, snapshot, metric, search, rows, summary); + } +} + +fn push_process_search_tree( + index: usize, + depth: usize, + snapshot: &Snapshot, + metric: Metric, + search: &Search, + covered_by_match: bool, + rows: &mut Vec, + summary: &mut SearchSummary, +) -> bool { + let node = &snapshot.process_tree.nodes[index]; + let direct = process_matches(search, node); + let mut child_rows = Vec::new(); + let mut child_visible = false; + let children = sorted_process_children(node, snapshot, metric, ProcessScope::SelfAndChildren); + for child in children { + child_visible |= push_process_search_tree( + child, + depth + 1, + snapshot, + metric, + search, + covered_by_match || direct, + &mut child_rows, + summary, + ); + } + if direct { + summary.hit(); + if !covered_by_match { + summary.attribute(node.subtree.metric(metric)); + } + } + let visible = direct || child_visible; + if visible { + rows.push(FlatProcessRow { + index, + pid: node.pid, + depth, + fold: if child_visible { + RowFold::Expanded + } else { + RowFold::Leaf + }, + search: if direct { + SearchRole::Match + } else { + SearchRole::Context + }, + }); + rows.extend(child_rows); + } + visible +} + +fn process_matches(search: &Search, node: &ProcessNode) -> bool { + search.matches(&node.name) + || search.matches(&node.command) + || search.matches(&node.username) + || search.matches(&node.state) + || search.matches(&node.pid.to_string()) +} + +fn largest_tmpfs_non_root_subtree(snapshot: &Snapshot) -> Bytes { + snapshot + .tmpfs_mounts + .iter() + .flat_map(|mount| mount.root.children.iter()) + .map(largest_tmpfs_subtree) + .max() + .unwrap_or(Bytes::ZERO) +} + +fn largest_tmpfs_subtree(node: &TmpfsNode) -> Bytes { + let child_max = node + .children + .iter() + .map(largest_tmpfs_subtree) + .max() + .unwrap_or(Bytes::ZERO); + if node.children.is_empty() { + child_max + } else { + child_max.max(node.allocated) + } +} + +fn push_tmpfs_rows( + mount_index: usize, + node: &TmpfsNode, + depth: usize, + policy: &FoldPolicy<'_, PathBuf>, + rows: &mut Vec, +) { + let fold = policy.row_fold(&node.path, depth, !node.children.is_empty(), node.allocated); + rows.push(FlatTmpfsRow { + mount_index, + path: node.path.clone(), + name: node.name.clone(), + kind: node.kind, + allocated: node.allocated, + logical: node.logical, + depth, + fold, + search: SearchRole::Ordinary, + }); + if fold.is_collapsed() { + return; + } + + for child in sorted_tmpfs_children(node) { + push_tmpfs_rows(mount_index, child, depth + 1, policy, rows); + } +} + +fn push_tmpfs_search_self( + mount_index: usize, + node: &TmpfsNode, + depth: usize, + search: &Search, + rows: &mut Vec, + summary: &mut SearchSummary, +) { + if tmpfs_matches(search, node) { + summary.strike(node.allocated); + rows.push(FlatTmpfsRow { + mount_index, + path: node.path.clone(), + name: node.name.clone(), + kind: node.kind, + allocated: node.allocated, + logical: node.logical, + depth, + fold: RowFold::Leaf, + search: SearchRole::Match, + }); + } + for child in sorted_tmpfs_children(node) { + push_tmpfs_search_self(mount_index, child, depth + 1, search, rows, summary); + } +} + +fn push_tmpfs_search_tree( + mount_index: usize, + node: &TmpfsNode, + depth: usize, + search: &Search, + covered_by_match: bool, + rows: &mut Vec, + summary: &mut SearchSummary, +) -> bool { + let direct = tmpfs_matches(search, node); + let mut child_rows = Vec::new(); + let mut child_visible = false; + for child in sorted_tmpfs_children(node) { + child_visible |= push_tmpfs_search_tree( + mount_index, + child, + depth + 1, + search, + covered_by_match || direct, + &mut child_rows, + summary, + ); + } + if direct { + summary.hit(); + if !covered_by_match { + summary.attribute(node.allocated); + } + } + let visible = direct || child_visible; + if visible { + rows.push(FlatTmpfsRow { + mount_index, + path: node.path.clone(), + name: node.name.clone(), + kind: node.kind, + allocated: node.allocated, + logical: node.logical, + depth, + fold: if child_visible { + RowFold::Expanded + } else { + RowFold::Leaf + }, + search: if direct { + SearchRole::Match + } else { + SearchRole::Context + }, + }); + rows.extend(child_rows); + } + visible +} + +fn sorted_tmpfs_children(node: &TmpfsNode) -> Vec<&'_ TmpfsNode> { + let mut children = node.children.iter().collect::>(); + children.sort_by(|lhs, rhs| { + rhs.allocated + .cmp(&lhs.allocated) + .then_with(|| lhs.path.cmp(&rhs.path)) + }); + children +} + +fn tmpfs_matches(search: &Search, node: &TmpfsNode) -> bool { + search.matches(&node.name) + || search.matches(node.kind.label()) + || search.matches(node.path.to_string_lossy().as_ref()) +} + +fn shared_matches(search: &Search, object: &SharedObject) -> bool { + search.matches(&object.label) || search.matches(object.kind.label()) +} + +fn process_cmp( + lhs: &ProcessNode, + rhs: &ProcessNode, + metric: Metric, + scope: ProcessScope, +) -> std::cmp::Ordering { + metric + .cmp_rollup(scope.rollup(lhs), scope.rollup(rhs)) + .then_with(|| lhs.pid.cmp(&rhs.pid)) +} diff --git a/crates/memview/src/app/tests.rs b/crates/memview/src/app/tests.rs new file mode 100644 index 0000000..1e9f8f8 --- /dev/null +++ b/crates/memview/src/app/tests.rs @@ -0,0 +1,390 @@ +use super::*; + +#[derive(Clone, Debug)] +struct TestRow(u8); + +impl IdentifiedRow for TestRow { + type Key = u8; + + fn key(&self) -> &Self::Key { + &self.0 + } +} + +fn tmpfs_mount(path: &str, allocated: Bytes) -> TmpfsMount { + TmpfsMount { + mount_point: PathBuf::from(path), + source: "tmpfs".to_string(), + size_limit: None, + root: TmpfsNode { + path: PathBuf::from(path), + name: path.to_string(), + kind: TmpfsNodeKind::Mount, + allocated, + logical: allocated, + children: Vec::new(), + }, + } +} + +fn tmpfs_tree(path: &str, allocated: Bytes, children: Vec) -> TmpfsMount { + TmpfsMount { + mount_point: PathBuf::from(path), + source: "tmpfs".to_string(), + size_limit: None, + root: TmpfsNode { + path: PathBuf::from(path), + name: path.to_string(), + kind: TmpfsNodeKind::Mount, + allocated, + logical: allocated, + children, + }, + } +} + +fn tmpfs_dir(path: &str, allocated: Bytes, children: Vec) -> TmpfsNode { + TmpfsNode { + path: PathBuf::from(path), + name: path.to_string(), + kind: TmpfsNodeKind::Directory, + allocated, + logical: allocated, + children, + } +} + +fn tmpfs_snapshot(mounts: Vec) -> Snapshot { + Snapshot { + captured_at: SystemTime::UNIX_EPOCH, + elapsed: Duration::ZERO, + meminfo: Meminfo::default(), + overview: crate::model::Overview::default(), + process_tree: crate::model::ProcessTree::default(), + shared_objects: Vec::new(), + sysv_segments: Vec::new(), + tmpfs_mounts: mounts, + warnings: Vec::new(), + } +} + +fn regex(pattern: &str) -> Search { + Search::compile(pattern.to_string()) + .expect("test regex compiles") + .expect("test regex is non-empty") +} + +fn next_process_scan_switch(commands: &Receiver) -> bool { + match commands.recv().expect("process scan switch command") { + WorkerCommand::SetProcessScanning(enabled) => enabled, + command => unreachable!("expected process scan switch, got {command:?}"), + } +} + +#[test] +fn de_minimis_chooses_lower_threshold() { + assert_eq!( + DeMinimis::from_largest_non_root(Bytes(10_000), Bytes(1_000)).threshold, + Bytes(30) + ); + assert_eq!( + DeMinimis::from_largest_non_root(Bytes(10_000), Bytes(100_000)).threshold, + Bytes(100) + ); +} + +#[test] +fn process_scanning_tracks_focus_and_active_tab() { + let (commands, events) = mpsc::channel(); + let mut app = App::new(); + + app.set_focused(false, &commands); + assert!(!next_process_scan_switch(&events)); + + app.select_tab(Tab::Processes, &commands); + assert!(!next_process_scan_switch(&events)); + + app.set_focused(true, &commands); + assert!(next_process_scan_switch(&events)); + + app.process_scan_started_at = Some(Instant::now()); + assert!(app.needs_periodic_redraw()); + app.set_focused(false, &commands); + assert!(!next_process_scan_switch(&events)); + assert!(!app.needs_periodic_redraw()); +} + +#[test] +fn fold_policy_respects_roots_leaves_and_manual_overrides() { + let collapsed = BTreeSet::new(); + let mut expanded = BTreeSet::new(); + let _ = expanded.insert(7); + let policy = FoldPolicy { + collapsed: &collapsed, + expanded: &expanded, + de_minimis: DeMinimis { + threshold: Bytes(100), + }, + }; + + assert_eq!(policy.row_fold(&1, 0, true, Bytes(1)), RowFold::Expanded); + assert_eq!(policy.row_fold(&1, 1, false, Bytes(1)), RowFold::Leaf); + assert_eq!(policy.row_fold(&1, 1, true, Bytes(99)), RowFold::Collapsed); + assert_eq!(policy.row_fold(&7, 1, true, Bytes(99)), RowFold::Expanded); +} + +#[test] +fn pane_rows_can_select_deleted_row_successor_slot() { + let mut rows = PaneRows::default(); + rows.install(vec![TestRow(1), TestRow(2), TestRow(3)]); + let _ = rows.move_by(1); + let successor = rows.selected_slot().expect("selected successor row slot"); + + rows.install(vec![TestRow(1), TestRow(3)]); + let _ = rows.select_clamped(successor); + assert_eq!(rows.selected().map(|row| row.0), Some(3)); + + rows.install(vec![TestRow(1)]); + let _ = rows.select_clamped(successor); + assert_eq!(rows.selected().map(|row| row.0), Some(1)); +} + +#[test] +fn tmpfs_background_rebuilds_stay_pinned_to_top_until_user_entry() { + let mut app = App::new(); + app.snapshot = Some(tmpfs_snapshot(vec![tmpfs_mount("/tmpfs-small", Bytes(1))])); + app.rebuild_tmpfs_rows(); + assert_eq!( + app.tmpfs_rows.selected().map(|row| row.path.as_path()), + Some(Path::new("/tmpfs-small")) + ); + + app.snapshot = Some(tmpfs_snapshot(vec![ + tmpfs_mount("/tmpfs-big", Bytes(2)), + tmpfs_mount("/tmpfs-small", Bytes(1)), + ])); + app.rebuild_tmpfs_rows(); + assert_eq!(app.selected_tmpfs_row(), 0); + assert_eq!( + app.tmpfs_rows.selected().map(|row| row.path.as_path()), + Some(Path::new("/tmpfs-big")) + ); +} + +#[test] +fn first_tmpfs_entry_seizes_top_then_preserves_user_anchor() { + let (commands, _events) = mpsc::channel(); + let mut app = App::new(); + app.snapshot = Some(tmpfs_snapshot(vec![ + tmpfs_mount("/tmpfs-big", Bytes(2)), + tmpfs_mount("/tmpfs-small", Bytes(1)), + ])); + app.rebuild_tmpfs_rows(); + let _ = app.tmpfs_rows.move_by(1); + + app.select_tab(Tab::Tmpfs, &commands); + assert_eq!(app.selected_tmpfs_row(), 0); + + app.snapshot = Some(tmpfs_snapshot(vec![ + tmpfs_mount("/tmpfs-bigger", Bytes(3)), + tmpfs_mount("/tmpfs-big", Bytes(2)), + tmpfs_mount("/tmpfs-small", Bytes(1)), + ])); + app.rebuild_tmpfs_rows(); + assert_eq!( + app.tmpfs_rows.selected().map(|row| row.path.as_path()), + Some(Path::new("/tmpfs-big")) + ); +} + +#[test] +fn optimistic_tmpfs_delete_prunes_immediately_and_keeps_successor_slot() { + let mut app = App::new(); + app.tab = Tab::Tmpfs; + app.snapshot = Some(tmpfs_snapshot(vec![tmpfs_tree( + "/proc/self/memview-delete-test", + Bytes(30), + vec![ + tmpfs_dir( + "/proc/self/memview-delete-test/victim", + Bytes(20), + Vec::new(), + ), + tmpfs_dir("/proc/self/memview-delete-test/next", Bytes(10), Vec::new()), + ], + )])); + app.rebuild_tmpfs_rows(); + let _ = app.tmpfs_rows.move_by(1); + + app.delete_current_tmpfs_entry(); + + assert_eq!(app.deletion_count(), 1); + assert_eq!( + app.tmpfs_rows() + .iter() + .map(|row| row.path.as_path()) + .collect::>(), + vec![ + Path::new("/proc/self/memview-delete-test"), + Path::new("/proc/self/memview-delete-test/next"), + ] + ); + assert_eq!(app.selected_tmpfs_row(), 1); +} + +#[test] +fn confirmed_tmpfs_tombstone_prunes_stale_scan_and_clears_after_absent_scan() { + let stale = tmpfs_tree( + "/tmp/memview-tombstone-test", + Bytes(30), + vec![ + tmpfs_dir("/tmp/memview-tombstone-test/victim", Bytes(20), Vec::new()), + tmpfs_dir("/tmp/memview-tombstone-test/next", Bytes(10), Vec::new()), + ], + ); + let fresh = tmpfs_tree( + "/tmp/memview-tombstone-test", + Bytes(10), + vec![tmpfs_dir( + "/tmp/memview-tombstone-test/next", + Bytes(10), + Vec::new(), + )], + ); + let victim = PathBuf::from("/tmp/memview-tombstone-test/victim"); + let mut app = App::new(); + app.snapshot = Some(tmpfs_snapshot(vec![stale.clone()])); + let _ = app.confirmed_deletions.insert(victim.clone()); + + app.install_tmpfs_mount_scan(probe::TmpfsMountScan { + captured_at: SystemTime::UNIX_EPOCH, + elapsed: Duration::ZERO, + mount: stale, + warnings: Vec::new(), + }); + assert!(!app.tmpfs_rows().iter().any(|row| row.path == victim)); + assert!(app.confirmed_deletions.contains(&victim)); + + app.install_tmpfs_mount_scan(probe::TmpfsMountScan { + captured_at: SystemTime::UNIX_EPOCH, + elapsed: Duration::ZERO, + mount: fresh, + warnings: Vec::new(), + }); + assert!(!app.confirmed_deletions.contains(&victim)); +} + +#[test] +fn tmpfs_search_self_mode_filters_to_direct_matches_and_sums_them() { + let mut app = App::new(); + app.snapshot = Some(tmpfs_snapshot(vec![tmpfs_tree( + "/mnt", + Bytes(35), + vec![ + tmpfs_dir("/mnt/batch-a", Bytes(10), Vec::new()), + tmpfs_dir("/mnt/batch-b", Bytes(20), Vec::new()), + tmpfs_dir("/mnt/other", Bytes(5), Vec::new()), + ], + )])); + app.search = Some(regex("batch-[ab]")); + app.rebuild_tmpfs_rows(); + + assert_eq!( + app.tmpfs_rows() + .iter() + .map(|row| row.path.as_path()) + .collect::>(), + vec![Path::new("/mnt/batch-b"), Path::new("/mnt/batch-a")] + ); + assert_eq!(app.tmpfs_search.matches, 2); + assert_eq!(app.tmpfs_search.total, Bytes(30)); +} + +#[test] +fn tmpfs_search_self_and_children_includes_context_parents_without_counting_them() { + let mut app = App::new(); + app.process_scope = ProcessScope::SelfAndChildren; + app.snapshot = Some(tmpfs_snapshot(vec![tmpfs_tree( + "/mnt", + Bytes(35), + vec![ + tmpfs_dir("/mnt/batch-a", Bytes(10), Vec::new()), + tmpfs_dir("/mnt/batch-b", Bytes(20), Vec::new()), + tmpfs_dir("/mnt/other", Bytes(5), Vec::new()), + ], + )])); + app.search = Some(regex("batch-[ab]")); + app.rebuild_tmpfs_rows(); + + assert_eq!( + app.tmpfs_rows() + .iter() + .map(|row| (row.path.as_path(), row.search)) + .collect::>(), + vec![ + (Path::new("/mnt"), SearchRole::Context), + (Path::new("/mnt/batch-b"), SearchRole::Match), + (Path::new("/mnt/batch-a"), SearchRole::Match), + ] + ); + assert_eq!(app.tmpfs_search.matches, 2); + assert_eq!(app.tmpfs_search.total, Bytes(30)); +} + +#[test] +fn tmpfs_search_self_and_children_does_not_double_count_nested_matches() { + let mut app = App::new(); + app.process_scope = ProcessScope::SelfAndChildren; + app.snapshot = Some(tmpfs_snapshot(vec![tmpfs_tree( + "/batch-root", + Bytes(30), + vec![tmpfs_dir("/batch-root/batch-child", Bytes(10), Vec::new())], + )])); + app.search = Some(regex("batch")); + app.rebuild_tmpfs_rows(); + + assert_eq!(app.tmpfs_search.matches, 2); + assert_eq!(app.tmpfs_search.total, Bytes(30)); +} + +#[test] +fn page_rows_match_left_table_viewport_height() { + assert_eq!(PageRows::from_terminal_height(32), PageRows(23)); + assert_eq!(PageRows::from_terminal_height(9), PageRows(1)); + assert_eq!(PageRows::from_terminal_height(0), PageRows(1)); +} + +#[test] +fn page_keys_move_one_visible_pane() { + let (commands, _events) = mpsc::channel(); + let mut app = App::new(); + app.tab = Tab::Tmpfs; + app.set_terminal_height(12); + app.tmpfs_rows.install( + (0..10) + .map(|index| FlatTmpfsRow { + mount_index: 0, + path: PathBuf::from(format!("/tmp/{index}")), + name: index.to_string(), + kind: TmpfsNodeKind::File, + allocated: Bytes::ZERO, + logical: Bytes::ZERO, + depth: 0, + fold: RowFold::Leaf, + search: SearchRole::Ordinary, + }) + .collect(), + ); + + let _ = app.handle_key( + KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE), + &commands, + ); + assert_eq!(app.selected_tmpfs_row(), 3); + + let _ = app.handle_key( + KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE), + &commands, + ); + assert_eq!(app.selected_tmpfs_row(), 0); +} diff --git a/crates/memview/src/app/worker.rs b/crates/memview/src/app/worker.rs new file mode 100644 index 0000000..52bbc59 --- /dev/null +++ b/crates/memview/src/app/worker.rs @@ -0,0 +1,368 @@ +use crate::model::{Pid, Snapshot}; +use crate::probe; +use color_eyre::eyre::Result; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::thread; +use std::time::{Duration, Instant}; + +const MIN_WORKER_REFRESH: Duration = Duration::from_secs(1); + +#[derive(Clone, Debug)] +pub enum WorkerCommand { + RefreshInventory, + RefreshProcesses, + RefreshProcessMappings(Pid), + RefreshSharedObjects, + RefreshTmpfsMounts, + RefreshTmpfsMount(PathBuf), + SetProcessScanning(bool), + Shutdown, +} + +#[derive(Debug)] +pub enum WorkerEvent { + InventoryReady(Result>), + TmpfsMountReady(Result>), + ProcessesStarted(Instant), + ProcessesReady(Result>), + ProcessMappingsReady(Result>), + SharedObjectsStarted(Instant), + SharedObjectsReady(Result>), +} + +pub fn spawn_worker(refresh_every: Duration) -> (Sender, Receiver) { + let refresh_every = refresh_every.max(MIN_WORKER_REFRESH); + let (command_tx, command_rx) = mpsc::channel(); + let (event_tx, event_rx) = mpsc::channel(); + let (inventory_tx, inventory_rx) = mpsc::channel(); + let (process_tx, process_rx) = mpsc::channel(); + + spawn_inventory_worker(inventory_rx, event_tx.clone()); + spawn_process_worker(refresh_every, process_rx, event_tx); + + let _handle = thread::spawn(move || { + while let Ok(command) = command_rx.recv() { + match command { + WorkerCommand::RefreshInventory + | WorkerCommand::RefreshTmpfsMounts + | WorkerCommand::RefreshTmpfsMount(_) => { + let _ = inventory_tx.send(command); + } + WorkerCommand::RefreshProcesses + | WorkerCommand::RefreshProcessMappings(_) + | WorkerCommand::RefreshSharedObjects + | WorkerCommand::SetProcessScanning(_) => { + let _ = process_tx.send(command); + } + WorkerCommand::Shutdown => { + let _ = inventory_tx.send(WorkerCommand::Shutdown); + let _ = process_tx.send(WorkerCommand::Shutdown); + break; + } + } + } + }); + + (command_tx, event_rx) +} + +fn spawn_inventory_worker(command_rx: Receiver, event_tx: Sender) { + let _handle = thread::spawn(move || { + loop { + match command_rx.recv() { + Ok(WorkerCommand::RefreshInventory) => { + if !publish_inventory_shell(&event_tx) || !publish_all_tmpfs_mounts(&event_tx) { + break; + } + } + Ok(WorkerCommand::RefreshTmpfsMounts) => { + if !publish_all_tmpfs_mounts(&event_tx) { + break; + } + } + Ok(WorkerCommand::RefreshTmpfsMount(path)) => { + if !publish_tmpfs_mount(&event_tx, &path) { + break; + } + } + Ok( + WorkerCommand::RefreshProcesses + | WorkerCommand::RefreshProcessMappings(_) + | WorkerCommand::RefreshSharedObjects + | WorkerCommand::SetProcessScanning(_), + ) => {} + Ok(WorkerCommand::Shutdown) | Err(_) => break, + } + } + }); +} + +#[derive(Clone, Copy, Debug)] +enum ProcessWorkerFlow { + Continue, + Shutdown, +} + +fn collect_process_command( + command: WorkerCommand, + active: &mut bool, + scan_once: &mut bool, + mapping_request: &mut Option, + shared_request: &mut bool, +) -> ProcessWorkerFlow { + match command { + WorkerCommand::SetProcessScanning(next) => *active = next, + WorkerCommand::RefreshProcesses => *scan_once = true, + WorkerCommand::RefreshProcessMappings(pid) => *mapping_request = Some(pid), + WorkerCommand::RefreshSharedObjects => *shared_request = true, + WorkerCommand::RefreshInventory + | WorkerCommand::RefreshTmpfsMounts + | WorkerCommand::RefreshTmpfsMount(_) => {} + WorkerCommand::Shutdown => return ProcessWorkerFlow::Shutdown, + } + ProcessWorkerFlow::Continue +} + +fn drain_process_commands( + command_rx: &Receiver, + active: &mut bool, + scan_once: &mut bool, + mapping_request: &mut Option, + shared_request: &mut bool, +) -> ProcessWorkerFlow { + while let Ok(command) = command_rx.try_recv() { + if matches!( + collect_process_command(command, active, scan_once, mapping_request, shared_request), + ProcessWorkerFlow::Shutdown + ) { + return ProcessWorkerFlow::Shutdown; + } + } + ProcessWorkerFlow::Continue +} + +fn publish_process_mappings(event_tx: &Sender, pid: Pid) -> ProcessWorkerFlow { + if event_tx + .send(WorkerEvent::ProcessMappingsReady( + probe::capture_process_mappings(pid).map(Box::new), + )) + .is_err() + { + ProcessWorkerFlow::Shutdown + } else { + ProcessWorkerFlow::Continue + } +} + +fn publish_shared_objects(event_tx: &Sender) -> ProcessWorkerFlow { + if event_tx + .send(WorkerEvent::SharedObjectsStarted(Instant::now())) + .is_err() + { + return ProcessWorkerFlow::Shutdown; + } + if event_tx + .send(WorkerEvent::SharedObjectsReady( + probe::capture_shared_objects().map(Box::new), + )) + .is_err() + { + ProcessWorkerFlow::Shutdown + } else { + ProcessWorkerFlow::Continue + } +} + +fn publish_processes(event_tx: &Sender) -> ProcessWorkerFlow { + if event_tx + .send(WorkerEvent::ProcessesStarted(Instant::now())) + .is_err() + { + return ProcessWorkerFlow::Shutdown; + } + if event_tx + .send(WorkerEvent::ProcessesReady( + probe::capture_processes().map(Box::new), + )) + .is_err() + { + ProcessWorkerFlow::Shutdown + } else { + ProcessWorkerFlow::Continue + } +} + +fn wait_process_command( + command_rx: &Receiver, + refresh_every: Duration, + active: &mut bool, + scan_once: &mut bool, + mapping_request: &mut Option, + shared_request: &mut bool, +) -> ProcessWorkerFlow { + match command_rx.recv_timeout(refresh_every) { + Ok(command) => { + collect_process_command(command, active, scan_once, mapping_request, shared_request) + } + Err(mpsc::RecvTimeoutError::Timeout) => ProcessWorkerFlow::Continue, + Err(mpsc::RecvTimeoutError::Disconnected) => ProcessWorkerFlow::Shutdown, + } +} + +fn publish_inventory_shell(event_tx: &Sender) -> bool { + event_tx + .send(WorkerEvent::InventoryReady( + probe::capture_inventory_shell().map(|capture| Box::new(capture.inventory_snapshot())), + )) + .is_ok() +} + +fn publish_all_tmpfs_mounts(event_tx: &Sender) -> bool { + let paths = match probe::tmpfs_mount_points() { + Ok(paths) => paths, + Err(error) => { + return event_tx + .send(WorkerEvent::TmpfsMountReady(Err(error))) + .is_ok(); + } + }; + + paths + .into_iter() + .all(|path| publish_tmpfs_mount(event_tx, &path)) +} + +fn publish_tmpfs_mount(event_tx: &Sender, path: &Path) -> bool { + event_tx + .send(WorkerEvent::TmpfsMountReady( + probe::capture_tmpfs_mount(path).map(Box::new), + )) + .is_ok() +} + +fn spawn_process_worker( + refresh_every: Duration, + command_rx: Receiver, + event_tx: Sender, +) { + let _handle = thread::spawn(move || { + let mut active = false; + let mut scan_once = false; + let mut mapping_request = None; + let mut shared_request = false; + + loop { + if !active && !scan_once && mapping_request.is_none() && !shared_request { + match command_rx.recv() { + Ok(command) => { + if matches!( + collect_process_command( + command, + &mut active, + &mut scan_once, + &mut mapping_request, + &mut shared_request, + ), + ProcessWorkerFlow::Shutdown + ) { + break; + } + } + Err(_) => break, + } + } + + if matches!( + drain_process_commands( + &command_rx, + &mut active, + &mut scan_once, + &mut mapping_request, + &mut shared_request, + ), + ProcessWorkerFlow::Shutdown + ) { + break; + } + + if let Some(pid) = mapping_request.take() { + if matches!( + publish_process_mappings(&event_tx, pid), + ProcessWorkerFlow::Shutdown + ) { + break; + } + if active + && !scan_once + && matches!( + wait_process_command( + &command_rx, + refresh_every, + &mut active, + &mut scan_once, + &mut mapping_request, + &mut shared_request, + ), + ProcessWorkerFlow::Shutdown + ) + { + break; + } + continue; + } + + if shared_request { + shared_request = false; + if matches!( + publish_shared_objects(&event_tx), + ProcessWorkerFlow::Shutdown + ) { + break; + } + if active + && !scan_once + && matches!( + wait_process_command( + &command_rx, + refresh_every, + &mut active, + &mut scan_once, + &mut mapping_request, + &mut shared_request, + ), + ProcessWorkerFlow::Shutdown + ) + { + break; + } + continue; + } + + if !active && !scan_once { + continue; + } + scan_once = false; + + if matches!(publish_processes(&event_tx), ProcessWorkerFlow::Shutdown) { + break; + } + + if active + && matches!( + wait_process_command( + &command_rx, + refresh_every, + &mut active, + &mut scan_once, + &mut mapping_request, + &mut shared_request, + ), + ProcessWorkerFlow::Shutdown + ) + { + break; + } + } + }); +} diff --git a/crates/memview/src/main.rs b/crates/memview/src/main.rs new file mode 100644 index 0000000..52580cb --- /dev/null +++ b/crates/memview/src/main.rs @@ -0,0 +1,160 @@ +#[cfg(not(target_os = "linux"))] +compile_error!("memview is Linux-only: it reads Linux /proc, tmpfs, SysV shm, and pidfd surfaces."); + +mod app; +mod model; +mod nav; +mod probe; +mod search; +mod ui; + +use crate::app::{App, WorkerCommand, spawn_worker}; +use clap::Parser; +use color_eyre::eyre::Result; +use crossterm::cursor::{Hide, Show}; +use crossterm::event::{ + self, DisableFocusChange, DisableMouseCapture, EnableFocusChange, EnableMouseCapture, Event, + KeyEventKind, +}; +use crossterm::execute; +use crossterm::terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, +}; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; +use std::io::{self, Stdout}; +use std::time::{Duration, Instant}; + +const FOCUSED_IDLE_POLL: Duration = Duration::from_millis(500); +const BACKGROUND_IDLE_POLL: Duration = Duration::from_secs(1); +const ANIMATED_REDRAW: Duration = Duration::from_millis(250); + +#[derive(Debug, Parser)] +#[command(author, version, about = "ncdu-like RAM accounting for Linux /proc")] +struct Cli { + #[arg(long, default_value_t = 5000)] + refresh_ms: u64, +} + +fn main() -> Result<()> { + color_eyre::install()?; + let cli = Cli::parse(); + let (commands, events) = spawn_worker(Duration::from_millis(cli.refresh_ms)); + let mut terminal = TerminalGuard::enter()?; + let mut app = App::new(); + app.set_terminal_height(terminal.height()?); + app.start_visible_work(&commands); + let mut dirty = true; + let mut next_animated_redraw = Instant::now(); + + loop { + while let Ok(event) = events.try_recv() { + app.apply_worker_event(event, &commands); + dirty = true; + } + if app.poll_deletion(&commands) { + dirty = true; + } + + let now = Instant::now(); + let redraw_animation = app.needs_periodic_redraw() && now >= next_animated_redraw; + if app.is_focused() && (dirty || redraw_animation) { + terminal.draw(|frame| ui::render(frame, &app))?; + dirty = false; + next_animated_redraw = Instant::now() + ANIMATED_REDRAW; + } + + if event::poll(poll_timeout(&app, next_animated_redraw))? { + match event::read()? { + Event::Key(key) => { + if key.kind != KeyEventKind::Press { + continue; + } + if app.handle_key(key, &commands) { + break; + } + dirty = true; + } + Event::Mouse(mouse) => { + if app.handle_mouse(mouse, &commands) { + dirty = true; + } + } + Event::Resize(_, height) => { + app.set_terminal_height(height); + dirty = true; + } + Event::FocusGained => { + app.set_focused(true, &commands); + dirty = true; + } + Event::FocusLost => { + app.set_focused(false, &commands); + dirty = false; + } + Event::Paste(_) => {} + } + } + } + + let _ = commands.send(WorkerCommand::Shutdown); + Ok(()) +} + +fn poll_timeout(app: &App, next_animated_redraw: Instant) -> Duration { + if !app.is_focused() { + return BACKGROUND_IDLE_POLL; + } + if app.needs_periodic_redraw() { + FOCUSED_IDLE_POLL.min(next_animated_redraw.saturating_duration_since(Instant::now())) + } else { + FOCUSED_IDLE_POLL + } +} + +struct TerminalGuard { + terminal: Terminal>, +} + +impl TerminalGuard { + fn enter() -> Result { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!( + stdout, + EnterAlternateScreen, + EnableMouseCapture, + EnableFocusChange, + Hide + )?; + let backend = CrosstermBackend::new(stdout); + let terminal = Terminal::new(backend)?; + Ok(Self { terminal }) + } + + fn draw(&mut self, draw: F) -> Result<()> + where + F: FnOnce(&mut ratatui::Frame<'_>), + { + let _ = self.terminal.draw(draw)?; + Ok(()) + } + + fn height(&self) -> Result { + Ok(self.terminal.size()?.height) + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = disable_raw_mode(); + let _ = execute!( + self.terminal.backend_mut(), + Show, + DisableFocusChange, + DisableMouseCapture, + LeaveAlternateScreen + ); + let _ = self.terminal.show_cursor(); + } +} diff --git a/crates/memview/src/model.rs b/crates/memview/src/model.rs new file mode 100644 index 0000000..a680332 --- /dev/null +++ b/crates/memview/src/model.rs @@ -0,0 +1,480 @@ +use std::cmp::Ordering; +use std::collections::BTreeMap; +use std::fmt::{self, Display, Formatter}; +use std::ops::{Add, AddAssign, Sub, SubAssign}; +use std::path::PathBuf; +use std::time::{Duration, SystemTime}; + +#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct Pid(pub i32); + +impl Display for Pid { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + Display::fmt(&self.0, f) + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct Bytes(pub u64); + +impl Bytes { + pub const ZERO: Self = Self(0); + const KIB: f64 = 1024.0; + const UNITS: [&str; 6] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]; + + #[must_use] + pub fn from_kib(kib: u64) -> Self { + Self(kib.saturating_mul(1024)) + } + + #[must_use] + pub fn from_blocks_512(blocks: u64) -> Self { + Self(blocks.saturating_mul(512)) + } + + #[must_use] + pub fn as_f64(self) -> f64 { + self.0 as f64 + } + + #[must_use] + pub fn pct_of(self, total: Self) -> f64 { + if total.0 == 0 { + 0.0 + } else { + (self.as_f64() * 100.0) / total.as_f64() + } + } + + #[must_use] + pub fn human_iec(self) -> String { + if self.0 < 1024 { + return format!("{} B", self.0); + } + + let mut value = self.as_f64(); + let mut unit = 0usize; + while value >= Self::KIB && unit + 1 < Self::UNITS.len() { + value /= Self::KIB; + unit += 1; + } + format!("{value:.1} {}", Self::UNITS[unit]) + } + + #[must_use] + pub fn human_exact(self) -> String { + format!("{} ({})", self.human_iec(), self.0) + } +} + +impl Add for Bytes { + type Output = Self; + + fn add(self, rhs: Self) -> Self::Output { + Self(self.0.saturating_add(rhs.0)) + } +} + +impl AddAssign for Bytes { + fn add_assign(&mut self, rhs: Self) { + self.0 = self.0.saturating_add(rhs.0); + } +} + +impl Sub for Bytes { + type Output = Self; + + fn sub(self, rhs: Self) -> Self::Output { + Self(self.0.saturating_sub(rhs.0)) + } +} + +impl SubAssign for Bytes { + fn sub_assign(&mut self, rhs: Self) { + self.0 = self.0.saturating_sub(rhs.0); + } +} + +impl Display for Bytes { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.write_str(&self.human_iec()) + } +} + +macro_rules! memory_rollup_apply { + ($lhs:expr, $rhs:expr, $op:tt) => {{ + $lhs.size $op $rhs.size; + $lhs.rss $op $rhs.rss; + $lhs.pss $op $rhs.pss; + $lhs.pss_dirty $op $rhs.pss_dirty; + $lhs.pss_anon $op $rhs.pss_anon; + $lhs.pss_file $op $rhs.pss_file; + $lhs.pss_shmem $op $rhs.pss_shmem; + $lhs.shared_clean $op $rhs.shared_clean; + $lhs.shared_dirty $op $rhs.shared_dirty; + $lhs.private_clean $op $rhs.private_clean; + $lhs.private_dirty $op $rhs.private_dirty; + $lhs.referenced $op $rhs.referenced; + $lhs.anonymous $op $rhs.anonymous; + $lhs.lazy_free $op $rhs.lazy_free; + $lhs.anon_huge_pages $op $rhs.anon_huge_pages; + $lhs.shmem_pmd_mapped $op $rhs.shmem_pmd_mapped; + $lhs.file_pmd_mapped $op $rhs.file_pmd_mapped; + $lhs.shared_hugetlb $op $rhs.shared_hugetlb; + $lhs.private_hugetlb $op $rhs.private_hugetlb; + $lhs.swap $op $rhs.swap; + $lhs.swap_pss $op $rhs.swap_pss; + $lhs.locked $op $rhs.locked; + }}; +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct MemoryRollup { + pub size: Bytes, + pub rss: Bytes, + pub pss: Bytes, + pub pss_dirty: Bytes, + pub pss_anon: Bytes, + pub pss_file: Bytes, + pub pss_shmem: Bytes, + pub shared_clean: Bytes, + pub shared_dirty: Bytes, + pub private_clean: Bytes, + pub private_dirty: Bytes, + pub referenced: Bytes, + pub anonymous: Bytes, + pub lazy_free: Bytes, + pub anon_huge_pages: Bytes, + pub shmem_pmd_mapped: Bytes, + pub file_pmd_mapped: Bytes, + pub shared_hugetlb: Bytes, + pub private_hugetlb: Bytes, + pub swap: Bytes, + pub swap_pss: Bytes, + pub locked: Bytes, +} + +impl MemoryRollup { + #[must_use] + pub fn uss(self) -> Bytes { + self.private_clean + self.private_dirty + self.private_hugetlb + } + + #[must_use] + pub fn shared(self) -> Bytes { + self.shared_clean + self.shared_dirty + self.shared_hugetlb + } + + #[must_use] + pub fn metric(self, metric: Metric) -> Bytes { + match metric { + Metric::Pss => self.pss, + Metric::Uss => self.uss(), + Metric::Rss => self.rss, + Metric::SwapPss => self.swap_pss, + Metric::Anonymous => self.pss_anon.max(self.anonymous), + Metric::File => self.pss_file, + Metric::Shmem => self.pss_shmem.max(self.shared()), + } + } +} + +impl Add for MemoryRollup { + type Output = Self; + + fn add(mut self, rhs: Self) -> Self::Output { + self += rhs; + self + } +} + +impl AddAssign for MemoryRollup { + fn add_assign(&mut self, rhs: Self) { + memory_rollup_apply!(self, rhs, +=); + } +} + +#[derive(Clone, Debug)] +pub struct MeminfoEntry { + pub key: String, + pub value: Bytes, +} + +#[derive(Clone, Debug, Default)] +pub struct Meminfo { + pub entries: Vec, + pub table: BTreeMap, +} + +impl Meminfo { + #[must_use] + pub fn get(&self, key: &str) -> Bytes { + self.table.get(key).copied().unwrap_or(Bytes::ZERO) + } +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum ObjectKind { + Anonymous, + SharedAnonymous, + Heap, + Stack, + File, + Tmpfs, + Memfd, + SysV, + Vdso, + Vvar, + Vsyscall, + Pseudo, +} + +impl ObjectKind { + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::Anonymous => "anon", + Self::SharedAnonymous => "shmem", + Self::Heap => "heap", + Self::Stack => "stack", + Self::File => "file", + Self::Tmpfs => "tmpfs", + Self::Memfd => "memfd", + Self::SysV => "sysv", + Self::Vdso => "vdso", + Self::Vvar => "vvar", + Self::Vsyscall => "vsyscall", + Self::Pseudo => "pseudo", + } + } +} + +#[derive(Clone, Debug)] +pub struct ObjectUsage { + pub kind: ObjectKind, + pub label: String, + pub rollup: MemoryRollup, + pub regions: usize, +} + +#[derive(Clone, Debug)] +pub struct ObjectConsumer { + pub pid: Pid, + pub name: String, + pub command: String, + pub rollup: MemoryRollup, +} + +#[derive(Clone, Debug)] +pub struct SharedObject { + pub kind: ObjectKind, + pub label: String, + pub rollup: MemoryRollup, + pub regions: usize, + pub mapped_processes: usize, + pub consumers: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LedgerState { + Exact, + Approximate, + Inaccessible, + Deferred, +} + +impl LedgerState { + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::Exact => "exact", + Self::Approximate => "approx", + Self::Inaccessible => "inaccessible", + Self::Deferred => "deferred", + } + } + + #[must_use] + pub fn is_inaccessible(self) -> bool { + matches!(self, Self::Approximate | Self::Inaccessible) + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct ProcessTreeStats { + pub observed_processes: usize, + pub inaccessible_rollups: usize, + pub inaccessible_maps: usize, +} + +#[derive(Clone, Debug)] +pub struct ProcessNode { + pub pid: Pid, + pub ppid: Option, + pub name: String, + pub command: String, + pub username: String, + pub state: String, + pub threads: u32, + pub rollup: MemoryRollup, + pub subtree: MemoryRollup, + pub children: Vec, + pub objects: Vec, + pub rollup_state: LedgerState, + pub mappings_state: LedgerState, +} + +impl ProcessNode { + #[must_use] + pub fn title(&self) -> String { + if self.command.is_empty() { + format!("{} [{}]", self.name, self.pid) + } else { + format!("{} [{}] {}", self.name, self.pid, self.command) + } + } +} + +#[derive(Clone, Debug, Default)] +pub struct ProcessTree { + pub roots: Vec, + pub nodes: Vec, + pub stats: ProcessTreeStats, +} + +#[derive(Clone, Debug)] +pub struct SysvSegment { + pub id: i32, + pub attachments: u32, + pub owner_uid: u32, + pub size: Bytes, + pub rss: Bytes, + pub swap: Bytes, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TmpfsNodeKind { + Mount, + Directory, + File, + Symlink, + Socket, + Fifo, + CharDevice, + BlockDevice, + Other, +} + +impl TmpfsNodeKind { + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::Mount => "mount", + Self::Directory => "dir", + Self::File => "file", + Self::Symlink => "link", + Self::Socket => "sock", + Self::Fifo => "fifo", + Self::CharDevice => "char", + Self::BlockDevice => "block", + Self::Other => "other", + } + } +} + +#[derive(Clone, Debug)] +pub struct TmpfsNode { + pub path: PathBuf, + pub name: String, + pub kind: TmpfsNodeKind, + pub allocated: Bytes, + pub logical: Bytes, + pub children: Vec, +} + +#[derive(Clone, Debug)] +pub struct TmpfsMount { + pub mount_point: PathBuf, + pub source: String, + pub size_limit: Option, + pub root: TmpfsNode, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Metric { + Pss, + Uss, + Rss, + SwapPss, + Anonymous, + File, + Shmem, +} + +impl Metric { + const ALL: [Self; 7] = [ + Self::Pss, + Self::Uss, + Self::Rss, + Self::SwapPss, + Self::Anonymous, + Self::File, + Self::Shmem, + ]; + + #[must_use] + pub fn next(self) -> Self { + let index = Self::ALL + .iter() + .position(|metric| *metric == self) + .unwrap_or(0); + Self::ALL[(index + 1) % Self::ALL.len()] + } + + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::Pss => "PSS", + Self::Uss => "USS", + Self::Rss => "RSS", + Self::SwapPss => "SwapPSS", + Self::Anonymous => "Anon", + Self::File => "File", + Self::Shmem => "Shmem", + } + } + + #[must_use] + pub fn cmp_rollup(self, lhs: MemoryRollup, rhs: MemoryRollup) -> Ordering { + rhs.metric(self).cmp(&lhs.metric(self)) + } +} + +#[derive(Clone, Debug, Default)] +pub struct Overview { + pub process_count: usize, + pub inaccessible_rollups: usize, + pub inaccessible_maps: usize, + pub process_pss_total: Bytes, + pub process_uss_total: Bytes, + pub process_rss_total: Bytes, + pub process_swap_pss_total: Bytes, + pub process_pss_anon_total: Bytes, + pub process_pss_file_total: Bytes, + pub process_pss_shmem_total: Bytes, + pub tmpfs_allocated_total: Bytes, + pub sysv_rss_total: Bytes, +} + +#[derive(Clone, Debug)] +pub struct Snapshot { + pub captured_at: SystemTime, + pub elapsed: Duration, + pub meminfo: Meminfo, + pub overview: Overview, + pub process_tree: ProcessTree, + pub shared_objects: Vec, + pub sysv_segments: Vec, + pub tmpfs_mounts: Vec, + pub warnings: Vec, +} diff --git a/crates/memview/src/nav.rs b/crates/memview/src/nav.rs new file mode 100644 index 0000000..88e85a9 --- /dev/null +++ b/crates/memview/src/nav.rs @@ -0,0 +1,217 @@ +#[derive(Clone, Copy, Debug)] +pub struct Hotkey { + pub key: &'static str, + pub action: &'static str, +} + +#[derive(Clone, Copy, Debug)] +pub struct HotkeySections { + pub global: &'static [Hotkey], + pub pane_title: &'static str, + pub pane: &'static [Hotkey], +} + +const GLOBAL_HOTKEYS: &[Hotkey] = &[ + Hotkey { + key: "1-4 / Tab", + action: "switch pane", + }, + Hotkey { + key: "?", + action: "show this help", + }, + Hotkey { + key: "/", + action: "filter current ledger rows by regexp; empty search clears", + }, + Hotkey { + key: "f", + action: "clear active regexp filter", + }, + Hotkey { + key: "Esc", + action: "no-op at top level; close modal/help", + }, + Hotkey { + key: "q / Ctrl-C", + action: "quit", + }, +]; + +const OVERVIEW_HOTKEYS: &[Hotkey] = &[ + Hotkey { + key: "r", + action: "refresh kernel counters, tmpfs mounts, and SysV shm", + }, + Hotkey { + key: "s", + action: "cycle memory lens", + }, +]; + +const PROCESS_HOTKEYS: &[Hotkey] = &[ + Hotkey { + key: "j/k / arrows", + action: "move selection", + }, + Hotkey { + key: "gg / G", + action: "jump to first or last row", + }, + Hotkey { + key: "PgUp / PgDn", + action: "move selection by one visible pane", + }, + Hotkey { + key: "wheel", + action: "move selection one row per detent", + }, + Hotkey { + key: "h/l / Left/Right", + action: "collapse or expand selected process", + }, + Hotkey { + key: "Enter", + action: "toggle selected process fold, including auto-folds", + }, + Hotkey { + key: "s", + action: "cycle sort metric", + }, + Hotkey { + key: "m", + action: "toggle self vs self+children accounting", + }, + Hotkey { + key: "r", + action: "force an immediate process memory rescan", + }, + Hotkey { + key: "K", + action: "confirm SIGTERM for selected process", + }, +]; + +const TMPFS_HOTKEYS: &[Hotkey] = &[ + Hotkey { + key: "j/k / arrows", + action: "move selection", + }, + Hotkey { + key: "gg / G", + action: "jump to first or last row", + }, + Hotkey { + key: "PgUp / PgDn", + action: "move selection by one visible pane", + }, + Hotkey { + key: "wheel", + action: "move selection one row per detent", + }, + Hotkey { + key: "h/l / Left/Right", + action: "collapse or expand selected entry", + }, + Hotkey { + key: "Enter", + action: "toggle selected directory fold, including auto-folds", + }, + Hotkey { + key: "m", + action: "toggle self vs self+children search context", + }, + Hotkey { + key: "r", + action: "refresh only the selected tmpfs mount", + }, + Hotkey { + key: "d", + action: "delete selected tmpfs entry recursively if directory", + }, +]; + +const SHARED_HOTKEYS: &[Hotkey] = &[ + Hotkey { + key: "j/k / arrows", + action: "move selection", + }, + Hotkey { + key: "gg / G", + action: "jump to first or last row", + }, + Hotkey { + key: "PgUp / PgDn", + action: "move selection by one visible pane", + }, + Hotkey { + key: "wheel", + action: "move selection one row per detent", + }, + Hotkey { + key: "s", + action: "cycle memory lens", + }, + Hotkey { + key: "m", + action: "toggle self vs self+children search context", + }, + Hotkey { + key: "r", + action: "force an immediate shared-object rescan", + }, +]; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Tab { + Overview, + Processes, + Tmpfs, + Shared, +} + +impl Tab { + pub const ALL: [Self; 4] = [Self::Overview, Self::Processes, Self::Tmpfs, Self::Shared]; + + #[must_use] + pub fn next(self) -> Self { + let index = Self::ALL.iter().position(|tab| *tab == self).unwrap_or(0); + Self::ALL[(index + 1) % Self::ALL.len()] + } + + #[must_use] + pub fn previous(self) -> Self { + let index = Self::ALL.iter().position(|tab| *tab == self).unwrap_or(0); + Self::ALL[(index + Self::ALL.len() - 1) % Self::ALL.len()] + } + + #[must_use] + pub fn title(self) -> &'static str { + match self { + Self::Overview => "Overview", + Self::Processes => "Processes", + Self::Tmpfs => "Tmpfs", + Self::Shared => "Shared", + } + } + + #[must_use] + pub fn drives_process_scans(self) -> bool { + matches!(self, Self::Processes) + } + + #[must_use] + pub fn hotkeys(self) -> &'static [Hotkey] { + match self { + Self::Overview => OVERVIEW_HOTKEYS, + Self::Processes => PROCESS_HOTKEYS, + Self::Tmpfs => TMPFS_HOTKEYS, + Self::Shared => SHARED_HOTKEYS, + } + } +} + +#[must_use] +pub fn global_hotkeys() -> &'static [Hotkey] { + GLOBAL_HOTKEYS +} diff --git a/crates/memview/src/probe.rs b/crates/memview/src/probe.rs new file mode 100644 index 0000000..1825ecb --- /dev/null +++ b/crates/memview/src/probe.rs @@ -0,0 +1,1417 @@ +use crate::model::{ + Bytes, LedgerState, Meminfo, MeminfoEntry, MemoryRollup, Metric, ObjectConsumer, ObjectKind, + ObjectUsage, Overview, Pid, ProcessNode, ProcessTree, ProcessTreeStats, SharedObject, Snapshot, + SysvSegment, TmpfsMount, TmpfsNode, TmpfsNodeKind, +}; +use color_eyre::eyre::{Context, Result, eyre}; +use std::cmp::Reverse; +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::ffi::OsStr; +use std::fs::{self, Metadata}; +use std::io; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::{FileTypeExt, MetadataExt}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant, SystemTime}; +use uzers::get_user_by_uid; +use walkdir::WalkDir; + +const DELETED_MAPPING_SUFFIX: &str = " (deleted)"; + +pub struct Capture { + started: Instant, + meminfo: Meminfo, + tmpfs_mounts: Vec, + sysv_segments: Vec, + warnings: Vec, +} + +impl Capture { + #[must_use] + pub fn inventory_snapshot(&self) -> Snapshot { + let process_tree = ProcessTree::default(); + let shared_objects = fold_shared_objects(&process_tree, &self.sysv_segments); + let overview = derive_overview(&process_tree, &self.tmpfs_mounts, &self.sysv_segments); + + Snapshot { + captured_at: SystemTime::now(), + elapsed: self.started.elapsed(), + meminfo: self.meminfo.clone(), + overview, + process_tree, + shared_objects, + sysv_segments: self.sysv_segments.clone(), + tmpfs_mounts: self.tmpfs_mounts.clone(), + warnings: self.warnings.clone(), + } + } +} + +#[derive(Debug)] +pub struct ProcessScan { + pub captured_at: SystemTime, + pub elapsed: Duration, + pub meminfo: Meminfo, + pub process_tree: ProcessTree, + pub warnings: Vec, +} + +#[derive(Debug)] +pub struct ProcessMappingScan { + pub elapsed: Duration, + pub cost: ProcessMappingCost, + pub pid: Pid, + pub objects: Vec, + pub mappings_state: LedgerState, + pub warnings: Vec, +} + +#[derive(Clone, Copy, Debug)] +pub struct ProcessMappingCost { + pub mount_index: Duration, + pub read: Duration, + pub parse: Duration, +} + +#[derive(Debug)] +pub struct SharedObjectsScan { + pub captured_at: SystemTime, + pub elapsed: Duration, + pub meminfo: Meminfo, + pub shared_objects: Vec, + pub warnings: Vec, +} + +#[derive(Debug)] +pub struct TmpfsMountScan { + pub captured_at: SystemTime, + pub elapsed: Duration, + pub mount: TmpfsMount, + pub warnings: Vec, +} + +impl ProcessScan { + pub fn install(self, snapshot: &mut Snapshot) { + snapshot.captured_at = self.captured_at; + snapshot.elapsed = self.elapsed; + snapshot.meminfo = self.meminfo; + snapshot.process_tree = self.process_tree; + rebuild_snapshot_derived(snapshot); + } +} + +pub fn capture_inventory_shell() -> Result { + let started = Instant::now(); + let mut warnings = Vec::new(); + let meminfo = read_meminfo().wrap_err("failed to read /proc/meminfo")?; + let _mount_index = MountIndex::read().wrap_err("failed to read /proc/self/mountinfo")?; + let sysv_segments = + read_sysv_segments(&mut warnings).wrap_err("failed to read /proc/sysvipc/shm")?; + + Ok(Capture { + started, + meminfo, + tmpfs_mounts: Vec::new(), + sysv_segments, + warnings, + }) +} + +pub fn capture_processes() -> Result { + let started = Instant::now(); + let mut warnings = Vec::new(); + let meminfo = read_meminfo().wrap_err("failed to read /proc/meminfo")?; + let forest = scan_processes(&mut warnings).wrap_err("failed to scan /proc")?; + Ok(ProcessScan { + captured_at: SystemTime::now(), + elapsed: started.elapsed(), + meminfo, + process_tree: build_process_tree(forest.processes, forest.stats), + warnings, + }) +} + +pub fn capture_process_mappings(pid: Pid) -> Result { + let started = Instant::now(); + let mut warnings = Vec::new(); + let mount_started = Instant::now(); + let mount_index = MountIndex::read().wrap_err("failed to read /proc/self/mountinfo")?; + let mount_index_elapsed = mount_started.elapsed(); + let root = PathBuf::from("/proc").join(pid.0.to_string()); + let read_started = Instant::now(); + let (objects, mappings_state, read_elapsed, parse_elapsed) = + match fs::read_to_string(root.join("smaps")) { + Ok(text) => { + let read_elapsed = read_started.elapsed(); + let parse_started = Instant::now(); + let objects = parse_smaps(&text, &mount_index); + ( + objects, + LedgerState::Exact, + read_elapsed, + parse_started.elapsed(), + ) + } + Err(error) => { + warnings.push(format!( + "selected process mappings unavailable for {pid}: {error}" + )); + ( + Vec::new(), + LedgerState::Inaccessible, + read_started.elapsed(), + Duration::ZERO, + ) + } + }; + + Ok(ProcessMappingScan { + elapsed: started.elapsed(), + cost: ProcessMappingCost { + mount_index: mount_index_elapsed, + read: read_elapsed, + parse: parse_elapsed, + }, + pid, + objects, + mappings_state, + warnings, + }) +} + +pub fn capture_shared_objects() -> Result { + let started = Instant::now(); + let mut warnings = Vec::new(); + let meminfo = read_meminfo().wrap_err("failed to read /proc/meminfo")?; + let mount_index = MountIndex::read().wrap_err("failed to read /proc/self/mountinfo")?; + let sysv_segments = + read_sysv_segments(&mut warnings).wrap_err("failed to read /proc/sysvipc/shm")?; + let mut processes = scan_process_shells(&mut warnings).wrap_err("failed to scan /proc")?; + attach_all_mapping_ledgers(&mut processes, &mount_index); + let stats = process_stats(&processes); + let process_tree = build_process_tree(processes, stats); + + Ok(SharedObjectsScan { + captured_at: SystemTime::now(), + elapsed: started.elapsed(), + meminfo, + shared_objects: fold_shared_objects(&process_tree, &sysv_segments), + warnings, + }) +} + +pub fn tmpfs_mount_points() -> Result> { + let mut warnings = Vec::new(); + let mount_index = MountIndex::read().wrap_err("failed to read /proc/self/mountinfo")?; + Ok(unique_tmpfs_infos(&mount_index, &mut warnings) + .into_iter() + .map(|info| info.mount_point) + .collect()) +} + +pub fn capture_tmpfs_mount(path: &Path) -> Result { + let started = Instant::now(); + let mut warnings = Vec::new(); + let mount_index = MountIndex::read().wrap_err("failed to read /proc/self/mountinfo")?; + let info = mount_index + .match_tmpfs_mount(path) + .cloned() + .ok_or_else(|| eyre!("no tmpfs mount contains {}", path.display()))?; + let mount = scan_tmpfs_mount(&info).map_err(|error| { + warnings.push(format!( + "tmpfs scan skipped for {}: {error}", + info.mount_point.display() + )); + error + })?; + + Ok(TmpfsMountScan { + captured_at: SystemTime::now(), + elapsed: started.elapsed(), + mount, + warnings, + }) +} + +pub fn rebuild_snapshot_derived(snapshot: &mut Snapshot) { + snapshot.overview = derive_overview( + &snapshot.process_tree, + &snapshot.tmpfs_mounts, + &snapshot.sysv_segments, + ); +} + +#[derive(Clone, Debug)] +struct MountInfo { + mount_point: PathBuf, + fs_type: String, + source: String, + super_options: String, +} + +#[derive(Clone, Debug, Default)] +struct MountIndex { + tmpfs: Vec, +} + +#[derive(Clone, Debug)] +struct TmpfsBuilder { + path: PathBuf, + name: String, + kind: TmpfsNodeKind, + own_allocated: Bytes, + own_logical: Bytes, + allocated: Bytes, + logical: Bytes, + children: Vec, +} + +impl MountIndex { + fn read() -> Result { + let text = fs::read_to_string("/proc/self/mountinfo")?; + let mut tmpfs_by_mountpoint = BTreeMap::new(); + + for line in text.lines().filter(|line| !line.is_empty()) { + let Some(info) = parse_mountinfo_line(line) else { + continue; + }; + if info.fs_type == "tmpfs" { + let _ = tmpfs_by_mountpoint + .entry(info.mount_point.clone()) + .or_insert(info); + } + } + let mut tmpfs = tmpfs_by_mountpoint.into_values().collect::>(); + tmpfs.sort_by_key(|info| Reverse(info.mount_point.as_os_str().len())); + Ok(Self { tmpfs }) + } + + fn match_tmpfs_mount<'a>(&'a self, path: &Path) -> Option<&'a MountInfo> { + self.tmpfs.iter().find(|info| { + path == info.mount_point + || path + .strip_prefix(&info.mount_point) + .is_ok_and(|suffix| !suffix.as_os_str().is_empty()) + }) + } +} + +fn parse_mountinfo_line(line: &str) -> Option { + let (left, right) = line.split_once(" - ")?; + let left_fields = left.split_whitespace().collect::>(); + let right_fields = right.split_whitespace().collect::>(); + if left_fields.len() < 5 || right_fields.len() < 3 { + return None; + } + + Some(MountInfo { + mount_point: PathBuf::from(unescape_mount_field(left_fields[4])), + fs_type: right_fields[0].to_string(), + source: right_fields[1].to_string(), + super_options: right_fields[2..].join(" "), + }) +} + +fn unescape_mount_field(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + let bytes = value.as_bytes(); + let mut index = 0usize; + + while index < bytes.len() { + if bytes[index] == b'\\' && index + 3 < bytes.len() { + let slice = &value[index + 1..index + 4]; + if let Ok(code) = u8::from_str_radix(slice, 8) { + out.push(char::from(code)); + index += 4; + continue; + } + } + + out.push(bytes[index].into()); + index += 1; + } + + out +} + +fn read_meminfo() -> Result { + let text = fs::read_to_string("/proc/meminfo")?; + Ok(parse_meminfo(&text)) +} + +fn parse_meminfo(text: &str) -> Meminfo { + #[derive(Clone, Debug)] + struct RawEntry<'a> { + key: &'a str, + number: u64, + unit: Option<&'a str>, + } + + let raw = text + .lines() + .filter(|line| !line.is_empty()) + .filter_map(|line| { + let (key, rest) = line.split_once(':')?; + let mut fields = rest.split_whitespace(); + Some(RawEntry { + key: key.trim(), + number: fields.next()?.parse().ok()?, + unit: fields.next(), + }) + }) + .collect::>(); + let hugepage_size = raw + .iter() + .find(|entry| entry.key == "Hugepagesize") + .map(|entry| Bytes::from_kib(entry.number)) + .unwrap_or(Bytes::ZERO); + let mut entries = Vec::new(); + let mut table = BTreeMap::new(); + + for entry in raw { + let value = meminfo_value(entry.key, entry.number, entry.unit, hugepage_size); + entries.push(MeminfoEntry { + key: entry.key.to_string(), + value, + }); + let _ = table.insert(entry.key.to_string(), value); + } + + Meminfo { entries, table } +} + +fn meminfo_value(key: &str, number: u64, unit: Option<&str>, hugepage_size: Bytes) -> Bytes { + if key.starts_with("HugePages_") { + return Bytes(number.saturating_mul(hugepage_size.0)); + } + + match unit { + Some("kB") => Bytes::from_kib(number), + _ => Bytes(number), + } +} + +fn read_sysv_segments(warnings: &mut Vec) -> Result> { + let text = match fs::read_to_string("/proc/sysvipc/shm") { + Ok(text) => text, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(error.into()), + }; + + let mut lines = text.lines(); + let Some(header) = lines.next() else { + return Ok(Vec::new()); + }; + let columns = header.split_whitespace().collect::>(); + let index = columns + .iter() + .enumerate() + .map(|(position, name)| (*name, position)) + .collect::>(); + + let mut segments = Vec::new(); + for line in lines.filter(|line| !line.trim().is_empty()) { + let fields = line.split_whitespace().collect::>(); + let Some(id) = parse_column::(&fields, &index, "shmid") else { + warnings.push(format!("ignoring malformed sysv shm row: {line}")); + continue; + }; + + segments.push(SysvSegment { + id, + attachments: parse_column::(&fields, &index, "nattch").unwrap_or(0), + owner_uid: parse_column::(&fields, &index, "uid").unwrap_or(0), + size: parse_column::(&fields, &index, "size") + .map(Bytes) + .unwrap_or(Bytes::ZERO), + rss: parse_column::(&fields, &index, "rss") + .map(Bytes) + .unwrap_or(Bytes::ZERO), + swap: parse_column::(&fields, &index, "swap") + .map(Bytes) + .unwrap_or(Bytes::ZERO), + }); + } + + segments.sort_by(|lhs, rhs| rhs.rss.cmp(&lhs.rss).then_with(|| lhs.id.cmp(&rhs.id))); + Ok(segments) +} + +fn parse_column( + fields: &[&str], + index: &HashMap<&str, usize>, + name: &str, +) -> Option { + let position = *index.get(name)?; + fields.get(position)?.parse().ok() +} + +#[derive(Clone, Debug)] +struct ScannedProcess { + pid: Pid, + ppid: Option, + name: String, + command: String, + username: String, + state: String, + threads: u32, + rollup: MemoryRollup, + objects: Vec, + rollup_state: LedgerState, + mappings_state: LedgerState, +} + +fn scan_process_shells(warnings: &mut Vec) -> Result> { + let mut processes = Vec::new(); + let mut usernames = BTreeMap::new(); + + for entry in fs::read_dir("/proc")? { + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + warnings.push(format!("ignoring /proc entry: {error}")); + continue; + } + }; + + let Ok(pid) = entry.file_name().to_string_lossy().parse::() else { + continue; + }; + + match scan_process_shell(Pid(pid), &mut usernames) { + Ok(Some(process)) => processes.push(process), + Ok(None) => {} + Err(error) => warnings.push(format!("ignoring pid {pid}: {error}")), + } + } + + Ok(processes) +} + +fn scan_processes(warnings: &mut Vec) -> Result { + let mut processes = scan_process_shells(warnings)?; + let stats = process_stats(&processes); + processes.sort_by_key(|process| process.pid); + Ok(ProcessForest { processes, stats }) +} + +fn scan_process_shell( + pid: Pid, + usernames: &mut BTreeMap, +) -> Result> { + let root = PathBuf::from("/proc").join(pid.0.to_string()); + let status_text = match fs::read_to_string(root.join("status")) { + Ok(text) => text, + Err(error) + if matches!( + error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::PermissionDenied + ) => + { + return Ok(None); + } + Err(error) => return Err(error.into()), + }; + + let status = parse_status(&status_text); + let command = read_cmdline(&root).unwrap_or_else(|| status.name.clone()); + let username = lookup_username(status.uid, usernames); + let fallback_rollup = MemoryRollup { + rss: status.vm_rss, + pss: status.vm_rss, + anonymous: status.rss_anon, + pss_anon: status.rss_anon, + pss_file: status.rss_file, + pss_shmem: status.rss_shmem, + swap: status.vm_swap, + ..MemoryRollup::default() + }; + let (rollup, rollup_state) = match fs::read_to_string(root.join("smaps_rollup")) { + Ok(text) => (parse_rollup_kv(&text), LedgerState::Exact), + Err(_) => (fallback_rollup, LedgerState::Approximate), + }; + + Ok(Some(ScannedProcess { + pid, + ppid: status.ppid, + name: status.name, + command, + username, + state: status.state, + threads: status.threads, + rollup, + objects: Vec::new(), + rollup_state, + mappings_state: LedgerState::Deferred, + })) +} + +#[derive(Clone, Debug)] +struct ProcessForest { + processes: Vec, + stats: ProcessTreeStats, +} + +fn process_stats(processes: &[ScannedProcess]) -> ProcessTreeStats { + ProcessTreeStats { + observed_processes: processes.len(), + inaccessible_rollups: processes + .iter() + .filter(|process| process.rollup_state.is_inaccessible()) + .count(), + inaccessible_maps: processes + .iter() + .filter(|process| process.mappings_state.is_inaccessible()) + .count(), + } +} + +fn attach_all_mapping_ledgers(processes: &mut [ScannedProcess], mount_index: &MountIndex) { + for process in processes.iter_mut() { + if process.rollup.rss == Bytes::ZERO && process.rollup.pss == Bytes::ZERO { + continue; + } + match fs::read_to_string( + PathBuf::from("/proc") + .join(process.pid.0.to_string()) + .join("smaps"), + ) { + Ok(text) => { + process.objects = parse_smaps(&text, mount_index); + process.mappings_state = LedgerState::Exact; + } + Err(_) => process.mappings_state = LedgerState::Inaccessible, + } + } +} + +#[derive(Clone, Debug)] +struct StatusSnapshot { + name: String, + ppid: Option, + uid: u32, + state: String, + threads: u32, + vm_rss: Bytes, + vm_swap: Bytes, + rss_anon: Bytes, + rss_file: Bytes, + rss_shmem: Bytes, +} + +fn parse_status(text: &str) -> StatusSnapshot { + let mut name = String::new(); + let mut ppid = None; + let mut uid = 0u32; + let mut state = "?".to_string(); + let mut threads = 0u32; + let mut vm_rss = Bytes::ZERO; + let mut vm_swap = Bytes::ZERO; + let mut rss_anon = Bytes::ZERO; + let mut rss_file = Bytes::ZERO; + let mut rss_shmem = Bytes::ZERO; + + for line in text.lines().filter(|line| !line.is_empty()) { + let Some((key, value)) = line.split_once(':') else { + continue; + }; + let value = value.trim(); + match key { + "Name" => name = value.to_string(), + "PPid" => ppid = value.parse::().ok().map(Pid), + "Uid" => { + uid = value + .split_whitespace() + .next() + .and_then(|field| field.parse().ok()) + .unwrap_or(0); + } + "State" => state = value.to_string(), + "Threads" => threads = value.parse().unwrap_or(0), + "VmRSS" => vm_rss = parse_status_kib(value).unwrap_or(Bytes::ZERO), + "VmSwap" => vm_swap = parse_status_kib(value).unwrap_or(Bytes::ZERO), + "RssAnon" => rss_anon = parse_status_kib(value).unwrap_or(Bytes::ZERO), + "RssFile" => rss_file = parse_status_kib(value).unwrap_or(Bytes::ZERO), + "RssShmem" => rss_shmem = parse_status_kib(value).unwrap_or(Bytes::ZERO), + _ => {} + } + } + + StatusSnapshot { + name, + ppid, + uid, + state, + threads, + vm_rss, + vm_swap, + rss_anon, + rss_file, + rss_shmem, + } +} + +fn parse_status_kib(value: &str) -> Option { + value + .split_whitespace() + .next() + .and_then(|field| field.parse::().ok()) + .map(Bytes::from_kib) +} + +fn read_cmdline(root: &Path) -> Option { + let bytes = fs::read(root.join("cmdline")).ok()?; + if bytes.is_empty() { + return None; + } + + let parts = bytes + .split(|byte| *byte == 0) + .filter(|part| !part.is_empty()) + .map(|part| String::from_utf8_lossy(part).into_owned()) + .collect::>(); + if parts.is_empty() { + None + } else { + Some(parts.join(" ")) + } +} + +fn lookup_username(uid: u32, cache: &mut BTreeMap) -> String { + cache + .entry(uid) + .or_insert_with(|| { + get_user_by_uid(uid) + .map(|user| String::from_utf8_lossy(user.name().as_bytes()).into_owned()) + .unwrap_or_else(|| uid.to_string()) + }) + .clone() +} + +fn parse_rollup_kv(text: &str) -> MemoryRollup { + let mut rollup = MemoryRollup::default(); + + for line in text.lines().skip(1) { + let Some((key, value)) = parse_kib_value(line) else { + continue; + }; + apply_rollup_field(&mut rollup, key, value); + } + + rollup +} + +fn parse_kib_value(line: &str) -> Option<(&str, Bytes)> { + let (key, rest) = line.split_once(':')?; + let value = rest.split_whitespace().next()?.parse::().ok()?; + Some((key.trim(), Bytes::from_kib(value))) +} + +fn apply_rollup_field(rollup: &mut MemoryRollup, key: &str, value: Bytes) { + match key { + "Size" => rollup.size = value, + "Rss" => rollup.rss = value, + "Pss" => rollup.pss = value, + "Pss_Dirty" => rollup.pss_dirty = value, + "Pss_Anon" => rollup.pss_anon = value, + "Pss_File" => rollup.pss_file = value, + "Pss_Shmem" => rollup.pss_shmem = value, + "Shared_Clean" => rollup.shared_clean = value, + "Shared_Dirty" => rollup.shared_dirty = value, + "Private_Clean" => rollup.private_clean = value, + "Private_Dirty" => rollup.private_dirty = value, + "Referenced" => rollup.referenced = value, + "Anonymous" => rollup.anonymous = value, + "LazyFree" => rollup.lazy_free = value, + "AnonHugePages" => rollup.anon_huge_pages = value, + "ShmemPmdMapped" => rollup.shmem_pmd_mapped = value, + "FilePmdMapped" => rollup.file_pmd_mapped = value, + "Shared_Hugetlb" => rollup.shared_hugetlb = value, + "Private_Hugetlb" => rollup.private_hugetlb = value, + "Swap" => rollup.swap = value, + "SwapPss" => rollup.swap_pss = value, + "Locked" => rollup.locked = value, + _ => {} + } +} + +fn parse_smaps(text: &str, mount_index: &MountIndex) -> Vec { + let mut objects = BTreeMap::<(ObjectKind, String), ObjectUsage>::new(); + let mut current = None::; + + for line in text.lines() { + if let Some(header) = parse_mapping_header(line) { + flush_mapping(&mut current, &mut objects); + current = Some(MappingAccumulator::new( + classify_mapping(&header.path, mount_index), + header.size, + )); + continue; + } + + if let Some((key, value)) = parse_kib_value(line) + && let Some(mapping) = current.as_mut() + { + apply_rollup_field(&mut mapping.rollup, key, value); + } + } + + flush_mapping(&mut current, &mut objects); + let mut rows = objects.into_values().collect::>(); + rows.sort_by(|lhs, rhs| { + Metric::Pss + .cmp_rollup(lhs.rollup, rhs.rollup) + .then_with(|| lhs.label.cmp(&rhs.label)) + }); + rows +} + +fn flush_mapping( + current: &mut Option, + objects: &mut BTreeMap<(ObjectKind, String), ObjectUsage>, +) { + let Some(mapping) = current.take() else { + return; + }; + + let key = (mapping.kind, mapping.label.clone()); + let entry = objects.entry(key).or_insert_with(|| ObjectUsage { + kind: mapping.kind, + label: mapping.label.clone(), + rollup: MemoryRollup::default(), + regions: 0, + }); + entry.rollup += mapping.rollup; + entry.regions += 1; +} + +#[derive(Clone, Debug)] +struct MappingAccumulator { + kind: ObjectKind, + label: String, + rollup: MemoryRollup, +} + +impl MappingAccumulator { + fn new(classified: ClassifiedMapping, size: Bytes) -> Self { + Self { + kind: classified.kind, + label: classified.label, + rollup: MemoryRollup { + size, + ..MemoryRollup::default() + }, + } + } +} + +#[derive(Clone, Debug)] +struct MappingHeader { + size: Bytes, + path: String, +} + +fn parse_mapping_header(line: &str) -> Option { + let mut cursor = 0usize; + let range = take_field(line, &mut cursor)?; + let _perms = take_field(line, &mut cursor)?; + let _offset = take_field(line, &mut cursor)?; + let _dev = take_field(line, &mut cursor)?; + let _inode = take_field(line, &mut cursor)?; + let path = line[cursor..].trim().to_string(); + + let (start, end) = range.split_once('-')?; + let start = u64::from_str_radix(start, 16).ok()?; + let end = u64::from_str_radix(end, 16).ok()?; + + Some(MappingHeader { + size: Bytes(end.saturating_sub(start)), + path, + }) +} + +fn take_field<'a>(line: &'a str, cursor: &mut usize) -> Option<&'a str> { + let bytes = line.as_bytes(); + while *cursor < bytes.len() && bytes[*cursor].is_ascii_whitespace() { + *cursor += 1; + } + if *cursor >= bytes.len() { + return None; + } + let start = *cursor; + while *cursor < bytes.len() && !bytes[*cursor].is_ascii_whitespace() { + *cursor += 1; + } + Some(&line[start..*cursor]) +} + +#[derive(Clone, Debug)] +struct ClassifiedMapping { + kind: ObjectKind, + label: String, +} + +fn classify_mapping(path: &str, mount_index: &MountIndex) -> ClassifiedMapping { + if path.is_empty() { + return ClassifiedMapping { + kind: ObjectKind::Anonymous, + label: "".to_string(), + }; + } + + let mut raw = path.to_string(); + let deleted = raw.ends_with(DELETED_MAPPING_SUFFIX); + if deleted { + raw.truncate(raw.len().saturating_sub(DELETED_MAPPING_SUFFIX.len())); + } + + if raw.starts_with('[') && raw.ends_with(']') { + let inner = &raw[1..raw.len() - 1]; + return match inner { + "heap" => ClassifiedMapping { + kind: ObjectKind::Heap, + label: "[heap]".to_string(), + }, + "vdso" => ClassifiedMapping { + kind: ObjectKind::Vdso, + label: "[vdso]".to_string(), + }, + "vvar" => ClassifiedMapping { + kind: ObjectKind::Vvar, + label: "[vvar]".to_string(), + }, + "vsyscall" => ClassifiedMapping { + kind: ObjectKind::Vsyscall, + label: "[vsyscall]".to_string(), + }, + _ if inner.starts_with("stack") => ClassifiedMapping { + kind: ObjectKind::Stack, + label: raw, + }, + _ if inner.starts_with("anon_shmem:") => ClassifiedMapping { + kind: ObjectKind::SharedAnonymous, + label: raw, + }, + _ if inner.starts_with("anon:") => ClassifiedMapping { + kind: ObjectKind::Anonymous, + label: raw, + }, + _ => ClassifiedMapping { + kind: ObjectKind::Pseudo, + label: raw, + }, + }; + } + + if raw.starts_with("/SYSV") { + return ClassifiedMapping { + kind: ObjectKind::SysV, + label: restore_deleted_suffix(raw, deleted), + }; + } + + if raw.starts_with("/memfd:") { + return ClassifiedMapping { + kind: ObjectKind::Memfd, + label: restore_deleted_suffix(raw, deleted), + }; + } + + let path = Path::new(&raw); + if mount_index.match_tmpfs_mount(path).is_some() { + return ClassifiedMapping { + kind: ObjectKind::Tmpfs, + label: restore_deleted_suffix(raw, deleted), + }; + } + + ClassifiedMapping { + kind: ObjectKind::File, + label: restore_deleted_suffix(raw, deleted), + } +} + +fn restore_deleted_suffix(raw: String, deleted: bool) -> String { + if deleted { + format!("{raw}{DELETED_MAPPING_SUFFIX}") + } else { + raw + } +} + +fn build_process_tree(processes: Vec, stats: ProcessTreeStats) -> ProcessTree { + let mut nodes = processes + .into_iter() + .map(|process| ProcessNode { + pid: process.pid, + ppid: process.ppid, + name: process.name, + command: process.command, + username: process.username, + state: process.state, + threads: process.threads, + rollup: process.rollup, + subtree: process.rollup, + children: Vec::new(), + objects: process.objects, + rollup_state: process.rollup_state, + mappings_state: process.mappings_state, + }) + .collect::>(); + + let by_pid = nodes + .iter() + .enumerate() + .map(|(index, node)| (node.pid, index)) + .collect::>(); + + let mut roots = Vec::new(); + for index in 0..nodes.len() { + let Some(ppid) = nodes[index].ppid else { + roots.push(index); + continue; + }; + match by_pid.get(&ppid).copied() { + Some(parent) if parent != index => nodes[parent].children.push(index), + _ => roots.push(index), + } + } + + for root in roots.clone() { + let _ = accumulate_subtree(root, &mut nodes); + } + + ProcessTree { + roots, + nodes, + stats, + } +} + +fn accumulate_subtree(index: usize, nodes: &mut [ProcessNode]) -> MemoryRollup { + let children = nodes[index].children.clone(); + let mut subtotal = nodes[index].rollup; + for child in children { + subtotal += accumulate_subtree(child, nodes); + } + nodes[index].subtree = subtotal; + subtotal +} + +fn fold_shared_objects( + process_tree: &ProcessTree, + sysv_segments: &[SysvSegment], +) -> Vec { + struct Accumulator { + kind: ObjectKind, + label: String, + rollup: MemoryRollup, + regions: usize, + consumers: Vec, + } + + let mut objects = BTreeMap::<(ObjectKind, String), Accumulator>::new(); + + for node in &process_tree.nodes { + for object in &node.objects { + let entry = objects + .entry((object.kind, object.label.clone())) + .or_insert_with(|| Accumulator { + kind: object.kind, + label: object.label.clone(), + rollup: MemoryRollup::default(), + regions: 0, + consumers: Vec::new(), + }); + entry.rollup += object.rollup; + entry.regions += object.regions; + entry.consumers.push(ObjectConsumer { + pid: node.pid, + name: node.name.clone(), + command: node.command.clone(), + rollup: object.rollup, + }); + } + } + + let mut rows = objects + .into_values() + .map(|mut acc| { + acc.consumers.sort_by(|lhs, rhs| { + Metric::Pss + .cmp_rollup(lhs.rollup, rhs.rollup) + .then_with(|| lhs.pid.cmp(&rhs.pid)) + }); + SharedObject { + kind: acc.kind, + label: acc.label, + rollup: acc.rollup, + regions: acc.regions, + mapped_processes: acc.consumers.len(), + consumers: acc.consumers, + } + }) + .collect::>(); + + for segment in sysv_segments { + rows.push(SharedObject { + kind: ObjectKind::SysV, + label: format!( + "sysv:{} owner:{} attaches:{} size:{}", + segment.id, + segment.owner_uid, + segment.attachments, + segment.size.human_iec() + ), + rollup: MemoryRollup { + rss: segment.rss, + swap: segment.swap, + ..MemoryRollup::default() + }, + regions: 1, + mapped_processes: segment.attachments as usize, + consumers: Vec::new(), + }); + } + + rows.sort_by(|lhs, rhs| { + Metric::Pss + .cmp_rollup(lhs.rollup, rhs.rollup) + .then_with(|| rhs.rollup.rss.cmp(&lhs.rollup.rss)) + .then_with(|| lhs.label.cmp(&rhs.label)) + }); + rows +} + +fn derive_overview( + process_tree: &ProcessTree, + tmpfs_mounts: &[TmpfsMount], + sysv_segments: &[SysvSegment], +) -> Overview { + let mut overview = Overview { + process_count: process_tree.stats.observed_processes, + inaccessible_rollups: process_tree.stats.inaccessible_rollups, + inaccessible_maps: process_tree.stats.inaccessible_maps, + ..Overview::default() + }; + + for node in &process_tree.nodes { + overview.process_pss_total += node.rollup.pss; + overview.process_uss_total += node.rollup.uss(); + overview.process_rss_total += node.rollup.rss; + overview.process_swap_pss_total += node.rollup.swap_pss; + overview.process_pss_anon_total += node.rollup.pss_anon; + overview.process_pss_file_total += node.rollup.pss_file; + overview.process_pss_shmem_total += node.rollup.pss_shmem; + } + + for mount in tmpfs_mounts { + overview.tmpfs_allocated_total += mount.root.allocated; + } + for segment in sysv_segments { + overview.sysv_rss_total += segment.rss; + } + + overview +} + +fn unique_tmpfs_infos(mount_index: &MountIndex, warnings: &mut Vec) -> Vec { + let mut infos = mount_index.tmpfs.iter().collect::>(); + let mut seen_devices = BTreeSet::new(); + let mut unique = Vec::new(); + infos.sort_by_key(|info| info.mount_point.as_os_str().len()); + + for info in infos { + match fs::symlink_metadata(&info.mount_point) { + Ok(metadata) if seen_devices.insert(metadata.dev()) => {} + Ok(_) => continue, + Err(error) => { + warnings.push(format!( + "tmpfs scan skipped for {}: {error}", + info.mount_point.display() + )); + continue; + } + } + + unique.push(info.clone()); + } + + unique +} + +fn scan_tmpfs_mount(info: &MountInfo) -> Result { + let root_meta = fs::symlink_metadata(&info.mount_point)?; + let mut seen_storage = BTreeSet::<(u64, u64)>::new(); + let _ = seen_storage.insert((root_meta.dev(), root_meta.ino())); + let mut nodes = BTreeMap::::new(); + let _ = nodes.insert( + info.mount_point.clone(), + TmpfsBuilder { + path: info.mount_point.clone(), + name: info.mount_point.display().to_string(), + kind: TmpfsNodeKind::Mount, + own_allocated: metadata_allocated(&root_meta), + own_logical: metadata_logical(&root_meta), + allocated: Bytes::ZERO, + logical: Bytes::ZERO, + children: Vec::new(), + }, + ); + + for entry in WalkDir::new(&info.mount_point) + .same_file_system(true) + .follow_links(false) + { + let entry = match entry { + Ok(entry) => entry, + Err(_) => continue, + }; + let path = entry.path(); + if path == info.mount_point { + continue; + } + + let metadata = match entry.metadata() { + Ok(metadata) => metadata, + Err(_) => continue, + }; + + let path_buf = path.to_path_buf(); + let first_storage_name = seen_storage.insert((metadata.dev(), metadata.ino())); + let own_allocated = if first_storage_name { + metadata_allocated(&metadata) + } else { + Bytes::ZERO + }; + let own_logical = if first_storage_name { + metadata_logical(&metadata) + } else { + Bytes::ZERO + }; + let parent = path + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| info.mount_point.clone()); + nodes + .entry(parent.clone()) + .or_insert_with(|| TmpfsBuilder { + path: parent.clone(), + name: basename(&parent), + kind: TmpfsNodeKind::Directory, + own_allocated: Bytes::ZERO, + own_logical: Bytes::ZERO, + allocated: Bytes::ZERO, + logical: Bytes::ZERO, + children: Vec::new(), + }) + .children + .push(path_buf.clone()); + + let _ = nodes.insert( + path_buf.clone(), + TmpfsBuilder { + path: path_buf, + name: basename(path), + kind: classify_tmpfs_entry(&metadata), + own_allocated, + own_logical, + allocated: Bytes::ZERO, + logical: Bytes::ZERO, + children: Vec::new(), + }, + ); + } + + let mut ordered = nodes.keys().cloned().collect::>(); + ordered.sort_by_key(|path| Reverse(path.components().count())); + for path in ordered { + let Some(node) = nodes.get_mut(&path) else { + continue; + }; + node.allocated += node.own_allocated; + node.logical += node.own_logical; + let allocated = node.allocated; + let logical = node.logical; + let parent = path.parent().map(Path::to_path_buf); + if let Some(parent) = parent.and_then(|parent| nodes.get_mut(&parent)) { + parent.allocated += allocated; + parent.logical += logical; + } + } + + let root = materialize_tmpfs_node(&info.mount_point, &mut nodes); + Ok(TmpfsMount { + mount_point: info.mount_point.clone(), + source: info.source.clone(), + size_limit: parse_tmpfs_size_limit(&info.super_options), + root, + }) +} + +fn materialize_tmpfs_node(path: &Path, nodes: &mut BTreeMap) -> TmpfsNode { + let builder = nodes.remove(path).unwrap_or_else(|| TmpfsBuilder { + path: path.to_path_buf(), + name: basename(path), + kind: TmpfsNodeKind::Other, + own_allocated: Bytes::ZERO, + own_logical: Bytes::ZERO, + allocated: Bytes::ZERO, + logical: Bytes::ZERO, + children: Vec::new(), + }); + + let mut children = builder + .children + .iter() + .map(|child| materialize_tmpfs_node(child, nodes)) + .collect::>(); + children.sort_by(|lhs, rhs| { + rhs.allocated + .cmp(&lhs.allocated) + .then_with(|| lhs.path.cmp(&rhs.path)) + }); + + TmpfsNode { + path: builder.path, + name: builder.name, + kind: builder.kind, + allocated: builder.allocated, + logical: builder.logical, + children, + } +} + +fn basename(path: &Path) -> String { + path.file_name() + .unwrap_or_else(|| OsStr::new("/")) + .to_string_lossy() + .into_owned() +} + +fn metadata_allocated(metadata: &Metadata) -> Bytes { + Bytes::from_blocks_512(metadata.blocks()) +} + +fn metadata_logical(metadata: &Metadata) -> Bytes { + Bytes(metadata.size()) +} + +fn classify_tmpfs_entry(metadata: &Metadata) -> TmpfsNodeKind { + let file_type = metadata.file_type(); + if file_type.is_dir() { + TmpfsNodeKind::Directory + } else if file_type.is_file() { + TmpfsNodeKind::File + } else if file_type.is_symlink() { + TmpfsNodeKind::Symlink + } else if file_type.is_socket() { + TmpfsNodeKind::Socket + } else if file_type.is_fifo() { + TmpfsNodeKind::Fifo + } else if file_type.is_char_device() { + TmpfsNodeKind::CharDevice + } else if file_type.is_block_device() { + TmpfsNodeKind::BlockDevice + } else { + TmpfsNodeKind::Other + } +} + +fn parse_tmpfs_size_limit(options: &str) -> Option { + options + .split(',') + .find_map(|option| option.strip_prefix("size=").and_then(parse_size_option)) +} + +fn parse_size_option(value: &str) -> Option { + let trimmed = value.trim(); + let digits = trimmed + .chars() + .take_while(char::is_ascii_digit) + .collect::(); + let suffix = &trimmed[digits.len()..]; + let number = digits.parse::().ok()?; + let multiplier = match suffix.to_ascii_lowercase().as_str() { + "" => 1, + "k" | "kb" => 1024, + "m" | "mb" => 1024_u64.pow(2), + "g" | "gb" => 1024_u64.pow(3), + "t" | "tb" => 1024_u64.pow(4), + "p" | "pb" => 1024_u64.pow(5), + _ => return None, + }; + Some(Bytes(number.saturating_mul(multiplier))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scanned_process(pid: i32, pss: u64) -> ScannedProcess { + ScannedProcess { + pid: Pid(pid), + ppid: None, + name: format!("p{pid}"), + command: format!("p{pid} --serve"), + username: "test".to_string(), + state: "S".to_string(), + threads: 1, + rollup: MemoryRollup { + pss: Bytes(pss), + rss: Bytes(pss), + ..MemoryRollup::default() + }, + objects: Vec::new(), + rollup_state: LedgerState::Exact, + mappings_state: LedgerState::Deferred, + } + } + + #[test] + fn process_stats_preserve_every_summary_process() { + let processes = vec![ + scanned_process(1, 9_900), + scanned_process(2, 50), + scanned_process(3, 50), + ]; + let stats = process_stats(&processes); + assert_eq!(stats.observed_processes, 3); + assert_eq!(stats.inaccessible_rollups, 0); + assert_eq!(stats.inaccessible_maps, 0); + } + + #[test] + fn parses_mountinfo() { + let line = "839 811 0:34 / /tmp rw,nosuid,nodev master:17 - tmpfs tmpfs rw,size=65909960k"; + let parsed = parse_mountinfo_line(line).expect("mountinfo"); + assert_eq!(parsed.mount_point, PathBuf::from("/tmp")); + assert_eq!(parsed.fs_type, "tmpfs"); + assert_eq!(parsed.source, "tmpfs"); + } + + #[test] + fn parses_mapping_header() { + let line = "7f1230000000-7f1230001000 rw-s 00000000 00:01 42 /memfd:cache shard (deleted)"; + let parsed = parse_mapping_header(line).expect("header"); + assert_eq!(parsed.size, Bytes(0x1000)); + assert!(parsed.path.contains("/memfd:cache shard")); + } + + #[test] + fn parses_size_option_units() { + assert_eq!(parse_size_option("64k"), Some(Bytes(64 * 1024))); + assert_eq!(parse_size_option("2m"), Some(Bytes(2 * 1024 * 1024))); + assert_eq!(parse_size_option("1g"), Some(Bytes(1024 * 1024 * 1024))); + } + + #[test] + fn converts_meminfo_hugepage_counts_to_bytes() { + let parsed = parse_meminfo( + "MemTotal: 1024 kB\nHugePages_Total: 3\nHugePages_Free: 2\nHugepagesize: 2048 kB\n", + ); + assert_eq!(parsed.get("MemTotal"), Bytes(1024 * 1024)); + assert_eq!(parsed.get("HugePages_Total"), Bytes(3 * 2048 * 1024)); + assert_eq!(parsed.get("HugePages_Free"), Bytes(2 * 2048 * 1024)); + } +} diff --git a/crates/memview/src/search.rs b/crates/memview/src/search.rs new file mode 100644 index 0000000..1b31618 --- /dev/null +++ b/crates/memview/src/search.rs @@ -0,0 +1,130 @@ +use crate::model::Bytes; +use regex::Regex; + +#[derive(Clone, Debug)] +pub struct Search { + pattern: String, + regex: Regex, +} + +impl Search { + pub fn compile(pattern: String) -> Result, String> { + if pattern.is_empty() { + return Ok(None); + } + Regex::new(&pattern) + .map(|regex| Some(Self { pattern, regex })) + .map_err(|error| error.to_string()) + } + + #[must_use] + pub fn pattern(&self) -> &str { + &self.pattern + } + + #[must_use] + pub fn matches(&self, value: &str) -> bool { + self.regex.is_match(value) + } +} + +#[derive(Clone, Debug, Default)] +pub struct SearchDraft { + input: String, + error: Option, +} + +impl SearchDraft { + #[must_use] + pub fn new(active: Option<&Search>) -> Self { + Self { + input: active.map_or_else(String::new, |search| search.pattern().to_string()), + error: None, + } + } + + #[must_use] + pub fn from_input(input: String) -> Self { + Self { input, error: None } + } + + #[must_use] + pub fn input(&self) -> &str { + &self.input + } + + #[must_use] + pub fn error(&self) -> Option<&str> { + self.error.as_deref() + } + + pub fn push(&mut self, character: char) { + self.input.push(character); + self.error = None; + } + + pub fn backspace(&mut self) { + let _ = self.input.pop(); + self.error = None; + } + + pub fn clear(&mut self) { + self.input.clear(); + self.error = None; + } + + pub fn fail(&mut self, error: String) { + self.error = Some(error); + } + + #[must_use] + pub fn into_input(self) -> String { + self.input + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum SearchRole { + #[default] + Ordinary, + Match, + Context, +} + +impl SearchRole { + #[must_use] + pub fn is_context(self) -> bool { + self == Self::Context + } +} + +#[derive(Clone, Debug)] +pub struct SearchSummary { + pub matches: usize, + pub total: Bytes, + pub lens: &'static str, +} + +impl SearchSummary { + #[must_use] + pub const fn new(lens: &'static str) -> Self { + Self { + matches: 0, + total: Bytes::ZERO, + lens, + } + } + + pub fn strike(&mut self, value: Bytes) { + self.matches += 1; + self.total += value; + } + + pub fn hit(&mut self) { + self.matches += 1; + } + + pub fn attribute(&mut self, value: Bytes) { + self.total += value; + } +} diff --git a/crates/memview/src/ui.rs b/crates/memview/src/ui.rs new file mode 100644 index 0000000..d170d74 --- /dev/null +++ b/crates/memview/src/ui.rs @@ -0,0 +1,1076 @@ +use crate::app::{App, FlatProcessRow, FlatSharedRow, FlatTmpfsRow, Hotkey, RowFold}; +use crate::model::{Bytes, MeminfoEntry, ObjectUsage, Pid, Snapshot, TmpfsMount}; +use crate::search::SearchRole; +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table, Wrap}; +use std::time::Duration; + +const BG: Color = Color::Rgb(12, 17, 24); +const FG: Color = Color::Rgb(221, 227, 234); +const MUTED: Color = Color::Rgb(129, 145, 160); +const ACCENT: Color = Color::Rgb(64, 184, 173); +const HOT: Color = Color::Rgb(227, 116, 94); +const GOLD: Color = Color::Rgb(236, 180, 71); + +pub fn render(frame: &mut Frame<'_>, app: &App) { + let area = frame.area(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(10), + Constraint::Length(2), + ]) + .split(area); + + frame.render_widget(header(app), chunks[0]); + match app.snapshot.as_ref() { + Some(snapshot) => render_body(frame, app, snapshot, chunks[1]), + None => render_loading(frame, app, chunks[1]), + } + frame.render_widget(footer(app), chunks[2]); + + if app.show_help { + render_help(frame, app, area); + } + if app.kill_confirmation.is_some() { + render_kill_confirmation(frame, app, area); + } + if app.search_draft().is_some() { + render_search_prompt(frame, app, area); + } +} + +fn render_body(frame: &mut Frame<'_>, app: &App, snapshot: &Snapshot, area: Rect) { + match app.tab { + crate::app::Tab::Overview => render_overview(frame, app, snapshot, area), + crate::app::Tab::Processes => render_processes(frame, app, snapshot, area), + crate::app::Tab::Tmpfs => render_tmpfs(frame, app, snapshot, area), + crate::app::Tab::Shared => render_shared(frame, app, snapshot, area), + } +} + +fn header(app: &App) -> Paragraph<'static> { + let mut spans = Vec::new(); + spans.push(Span::styled( + " memview ", + Style::default() + .fg(BG) + .bg(ACCENT) + .add_modifier(Modifier::BOLD), + )); + spans.push(Span::raw(" ")); + for (index, label) in App::tab_labels().into_iter().enumerate() { + let style = if app.tab == crate::app::Tab::ALL[index] { + Style::default().fg(ACCENT).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(MUTED) + }; + spans.push(Span::styled(label, style)); + spans.push(Span::raw(" ")); + } + spans.push(Span::styled( + format!("sort {}", app.metric.label()), + Style::default().fg(GOLD), + )); + spans.push(Span::raw(" ")); + spans.push(Span::styled( + format!("mode {}", app.process_scope.label()), + Style::default().fg(ACCENT), + )); + + Paragraph::new(Line::from(spans)) + .block(panel("Memory Ledger")) + .style(Style::default().fg(FG).bg(BG)) +} + +fn footer(app: &App) -> Paragraph<'static> { + let mut spans = Vec::new(); + if let Some(pattern) = app.search_pattern() { + spans.push(Span::styled( + format!(" FILTER /{pattern}/ "), + Style::default() + .fg(BG) + .bg(GOLD) + .add_modifier(Modifier::BOLD), + )); + spans.push(Span::raw(" ")); + spans.push(Span::styled( + "f clear", + Style::default().fg(HOT).add_modifier(Modifier::BOLD), + )); + spans.push(Span::raw(" ")); + } + spans.push(Span::styled( + format!( + "q quit / search ? help {} {}", + pane_footer(app), + app.current_time_label() + ), + Style::default().fg(MUTED), + )); + if let Some(error) = &app.last_error { + spans.push(Span::styled(" last error: ", Style::default().fg(HOT))); + spans.push(Span::styled(error.clone(), Style::default().fg(HOT))); + } + if app.deletion_count() > 0 { + spans.push(Span::styled( + format!(" deleting {} in background", app.deletion_count()), + Style::default().fg(HOT), + )); + } + if let Some(confirmation) = &app.kill_confirmation { + if confirmation.armed() { + spans.push(Span::styled( + " SIGTERM armed: y confirms", + Style::default().fg(HOT).add_modifier(Modifier::BOLD), + )); + } else { + spans.push(Span::styled( + format!( + " SIGTERM locked {} ms", + confirmation.lock_remaining().as_millis() + ), + Style::default().fg(GOLD), + )); + } + } + if app.tab.drives_process_scans() + && let Some(started) = app.process_scan_started_at + { + spans.push(Span::styled( + format!(" process scan {} ms", started.elapsed().as_millis()), + Style::default().fg(MUTED), + )); + } + if let Some((pid, started)) = app.process_mapping_started_at() { + spans.push(Span::styled( + format!(" mappings {pid} {} ms", started.elapsed().as_millis()), + Style::default().fg(MUTED), + )); + } + if let Some(started) = app.shared_scan_started_at() { + spans.push(Span::styled( + format!(" shared ledger {} ms", started.elapsed().as_millis()), + Style::default().fg(MUTED), + )); + } + Paragraph::new(Line::from(spans)).style(Style::default().bg(BG)) +} + +fn pane_footer(app: &App) -> &'static str { + match app.tab { + crate::app::Tab::Overview => "r refresh overview s lens", + crate::app::Tab::Processes => { + "j/k/Pg/wheel move gg/G edge Enter fold s sort m mode K SIGTERM r rescan" + } + crate::app::Tab::Tmpfs => { + "j/k/Pg/wheel move gg/G edge Enter fold m mode d delete r refresh mount" + } + crate::app::Tab::Shared => "j/k/Pg/wheel move gg/G edge s sort m mode r rescan", + } +} + +fn render_loading(frame: &mut Frame<'_>, app: &App, area: Rect) { + let (title, message) = match app.tab { + crate::app::Tab::Overview => ("Loading", "Reading kernel memory counters..."), + crate::app::Tab::Processes => ("Processes", "Capturing process memory snapshot..."), + crate::app::Tab::Tmpfs => ("Tmpfs", "Scanning tmpfs mounts..."), + crate::app::Tab::Shared => ("Shared", "Reading shared memory ledgers..."), + }; + frame.render_widget( + Paragraph::new(message) + .block(panel(title)) + .style(Style::default().fg(FG)), + area, + ); +} + +fn render_overview(frame: &mut Frame<'_>, _app: &App, snapshot: &Snapshot, area: Rect) { + let capacity = snapshot.meminfo.get("MemTotal"); + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(52), Constraint::Percentage(48)]) + .split(area); + let right = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(11), Constraint::Min(8)]) + .split(columns[1]); + + let mem_rows = snapshot + .meminfo + .entries + .iter() + .map(|entry| row_meminfo(entry, capacity)) + .collect::>(); + frame.render_widget( + Table::new( + mem_rows, + [ + Constraint::Length(18), + Constraint::Length(16), + Constraint::Length(8), + ], + ) + .header(header_row(["Kernel Counter", "Value", "% total"])) + .block(panel("meminfo")) + .column_spacing(1), + columns[0], + ); + + let overview_rows = vec![ + summary_row( + "Σ process PSS", + snapshot.overview.process_pss_total, + capacity, + ), + summary_row( + "Σ process USS", + snapshot.overview.process_uss_total, + capacity, + ), + summary_row( + "Σ process RSS", + snapshot.overview.process_rss_total, + capacity, + ), + summary_row( + "Σ process SwapPSS", + snapshot.overview.process_swap_pss_total, + capacity, + ), + summary_row( + "Σ process PSS anon", + snapshot.overview.process_pss_anon_total, + capacity, + ), + summary_row( + "Σ process PSS file", + snapshot.overview.process_pss_file_total, + capacity, + ), + summary_row( + "Σ process PSS shmem", + snapshot.overview.process_pss_shmem_total, + capacity, + ), + summary_row( + "Σ tmpfs allocated", + snapshot.overview.tmpfs_allocated_total, + capacity, + ), + summary_row("Σ SysV shm RSS", snapshot.overview.sysv_rss_total, capacity), + summary_text_row("processes", &snapshot.overview.process_count.to_string()), + summary_text_row("SysV segments", &snapshot.sysv_segments.len().to_string()), + summary_text_row("scan millis", &snapshot.elapsed.as_millis().to_string()), + summary_row( + "inaccessible rollups", + Bytes(snapshot.overview.inaccessible_rollups as u64), + capacity, + ), + summary_row( + "inaccessible maps", + Bytes(snapshot.overview.inaccessible_maps as u64), + capacity, + ), + ]; + frame.render_widget( + Table::new( + overview_rows, + [Constraint::Length(24), Constraint::Length(16)], + ) + .header(header_row(["Lens", "Value"])) + .block(panel("reconciliation")) + .column_spacing(1), + right[0], + ); + + let warning_lines = if snapshot.warnings.is_empty() { + vec![Line::from(Span::styled( + "No probe warnings. PSS is the attribution lens; tmpfs uses allocated blocks.", + Style::default().fg(FG), + ))] + } else { + snapshot + .warnings + .iter() + .take(24) + .map(|warning| Line::from(Span::styled(warning.clone(), Style::default().fg(HOT)))) + .collect::>() + }; + frame.render_widget( + Paragraph::new(warning_lines) + .block(panel("probe notes")) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(FG)), + right[1], + ); +} + +fn render_processes(frame: &mut Frame<'_>, app: &App, snapshot: &Snapshot, area: Rect) { + let capacity = snapshot.meminfo.get("MemTotal"); + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(57), Constraint::Percentage(43)]) + .split(area); + let right = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(12), Constraint::Min(8)]) + .split(columns[1]); + + if snapshot.process_tree.nodes.is_empty() && app.process_scan_started_at.is_some() { + frame.render_widget( + Paragraph::new("Capturing process memory snapshot...") + .block(panel("process tree")) + .style(Style::default().fg(FG)), + columns[0], + ); + frame.render_widget( + Paragraph::new("Per-process PSS, mappings, and object consumers will appear here.") + .block(panel("selected process")) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(MUTED)), + right[0], + ); + return; + } + + let rows = app.process_rows(); + let selected = app.selected_process_row(); + let visible = slice_window(rows, selected, columns[0].height.saturating_sub(4) as usize); + let process_rows = visible + .iter() + .enumerate() + .map(|(offset, row)| { + row_process( + app, + snapshot, + row, + visible.start + offset == selected, + capacity, + ) + }) + .collect::>(); + frame.render_widget( + Table::new( + process_rows, + [ + Constraint::Length(30), + Constraint::Length(8), + Constraint::Length(8), + Constraint::Length(9), + Constraint::Length(9), + Constraint::Length(9), + Constraint::Min(24), + ], + ) + .header(header_row([ + "Task", "PID", "User", "PSS", "USS", "RSS", "Command", + ])) + .block(panel(&format!( + "process tree ({})", + app.process_scope.label() + ))) + .column_spacing(1), + columns[0], + ); + + if let Some(process) = app.selected_process() { + let mut details = search_summary_lines(app, capacity); + details.extend([ + Line::from(vec![Span::styled( + process.title(), + Style::default().fg(ACCENT).add_modifier(Modifier::BOLD), + )]), + detail_line("State", &process.state), + detail_line("Threads", &process.threads.to_string()), + detail_line("PSS", &process.rollup.pss.human_exact()), + detail_line("USS", &process.rollup.uss().human_exact()), + detail_line("RSS", &process.rollup.rss.human_exact()), + detail_line("PSS anon", &process.rollup.pss_anon.human_exact()), + detail_line("PSS file", &process.rollup.pss_file.human_exact()), + detail_line("PSS shmem", &process.rollup.pss_shmem.human_exact()), + detail_line("SwapPSS", &process.rollup.swap_pss.human_exact()), + detail_line( + "Access", + &format!( + "rollup={} maps={}", + process.rollup_state.label(), + app.selected_process_mapping_status() + ), + ), + detail_line("Map scan", &app.selected_process_mapping_scan_label()), + ]); + frame.render_widget( + Paragraph::new(details) + .block(panel("selected process")) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(FG)), + right[0], + ); + } else if app.search_summary().is_some() { + frame.render_widget( + Paragraph::new(search_summary_lines(app, capacity)) + .block(panel("search total")) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(FG)), + right[0], + ); + } + + if let Some((pid, elapsed)) = app.selected_process_mapping_loading() { + frame.render_widget(mapping_loading(pid, elapsed), right[1]); + } else { + let objects = app.selected_process_objects(); + let object_rows = slice_window(objects, 0, right[1].height.saturating_sub(4) as usize) + .iter() + .map(|object| row_object_usage(object, capacity)) + .collect::>(); + frame.render_widget( + Table::new( + object_rows, + [ + Constraint::Length(9), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(7), + Constraint::Min(24), + ], + ) + .header(header_row(["Kind", "PSS", "RSS", "VMAs", "Object"])) + .block(panel("selected mappings")) + .column_spacing(1), + right[1], + ); + } +} + +fn mapping_loading(pid: Pid, elapsed: Duration) -> Paragraph<'static> { + let dots = ".".repeat(((elapsed.as_millis() / 250) % 4) as usize); + Paragraph::new(vec![ + Line::from(vec![Span::styled( + format!("Loading /proc/{pid}/smaps{dots}"), + Style::default().fg(GOLD).add_modifier(Modifier::BOLD), + )]), + Line::from(""), + Line::from(vec![ + Span::styled("elapsed ", Style::default().fg(MUTED)), + Span::styled( + format!("{} ms", elapsed.as_millis()), + Style::default().fg(FG), + ), + ]), + Line::from(""), + Line::from("The kernel synthesizes per-VMA PSS/RSS here; large mapping tables can stall."), + ]) + .block(panel("selected mappings")) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(FG)) +} + +fn render_tmpfs(frame: &mut Frame<'_>, app: &App, _snapshot: &Snapshot, area: Rect) { + let capacity = app + .snapshot + .as_ref() + .map_or(Bytes::ZERO, |snapshot| snapshot.meminfo.get("MemTotal")); + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(58), Constraint::Percentage(42)]) + .split(area); + let rows = app.tmpfs_rows(); + let selected = app.selected_tmpfs_row(); + let visible = slice_window(rows, selected, columns[0].height.saturating_sub(4) as usize); + let table_rows = visible + .iter() + .enumerate() + .map(|(offset, row)| row_tmpfs(row, visible.start + offset == selected, capacity)) + .collect::>(); + frame.render_widget( + Table::new( + table_rows, + [ + Constraint::Length(32), + Constraint::Length(8), + Constraint::Length(12), + Constraint::Length(12), + Constraint::Min(20), + ], + ) + .header(header_row([ + "Entry", + "Kind", + "Allocated", + "Logical", + "Path", + ])) + .block(panel("tmpfs tree")) + .column_spacing(1), + columns[0], + ); + + let mut detail_lines = search_summary_lines(app, capacity); + detail_lines.extend( + match (app.selected_tmpfs_mount(), app.selected_tmpfs_entry()) { + (Some(mount), Some(row)) => tmpfs_detail_lines(mount, row), + _ => vec![Line::from("No tmpfs node selected")], + }, + ); + frame.render_widget( + Paragraph::new(detail_lines) + .block(panel("selected tmpfs entry")) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(FG)), + columns[1], + ); +} + +fn render_shared(frame: &mut Frame<'_>, app: &App, snapshot: &Snapshot, area: Rect) { + let capacity = snapshot.meminfo.get("MemTotal"); + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(58), Constraint::Percentage(42)]) + .split(area); + let rows = app.shared_rows(); + let selected = app.selected_shared_row(); + let visible = slice_window(rows, selected, columns[0].height.saturating_sub(4) as usize); + let rows = visible + .iter() + .enumerate() + .map(|(offset, row)| { + row_shared(snapshot, row, visible.start + offset == selected, capacity) + }) + .collect::>(); + frame.render_widget( + Table::new( + rows, + [ + Constraint::Length(8), + Constraint::Length(7), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(7), + Constraint::Min(24), + ], + ) + .header(header_row([ + "Kind", "Tasks", "PSS", "RSS", "VMAs", "Object", + ])) + .block(panel("global object ledger")) + .column_spacing(1), + columns[0], + ); + + let right = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(8), Constraint::Min(8)]) + .split(columns[1]); + if let Some(object) = app.selected_shared_object() { + let mut summary = search_summary_lines(app, capacity); + summary.extend([ + Line::from(Span::styled( + object.label.clone(), + Style::default().fg(ACCENT).add_modifier(Modifier::BOLD), + )), + detail_line("Kind", object.kind.label()), + detail_line("PSS", &object.rollup.pss.human_exact()), + detail_line("RSS", &object.rollup.rss.human_exact()), + detail_line("Swap", &object.rollup.swap.human_exact()), + detail_line("Tasks", &object.mapped_processes.to_string()), + detail_line("VMAs", &object.regions.to_string()), + ]); + frame.render_widget( + Paragraph::new(summary) + .block(panel("selected object")) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(FG)), + right[0], + ); + + let consumers = slice_window( + &object.consumers, + 0, + right[1].height.saturating_sub(4) as usize, + ) + .iter() + .map(|consumer| { + Row::new(vec![ + Cell::from(consumer.pid.to_string()), + usage_cell(consumer.rollup.pss, capacity), + usage_cell(consumer.rollup.rss, capacity), + Cell::from(consumer.name.clone()), + Cell::from(consumer.command.clone()), + ]) + .style(usage_style(false, consumer.rollup.pss, capacity)) + }) + .collect::>(); + frame.render_widget( + Table::new( + consumers, + [ + Constraint::Length(8), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(16), + Constraint::Min(20), + ], + ) + .header(header_row(["PID", "PSS", "RSS", "Name", "Command"])) + .block(panel("top consumers")) + .column_spacing(1), + right[1], + ); + } else if app.search_summary().is_some() { + frame.render_widget( + Paragraph::new(search_summary_lines(app, capacity)) + .block(panel("search total")) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(FG)), + right[0], + ); + } +} + +fn row_meminfo(entry: &MeminfoEntry, total: Bytes) -> Row<'static> { + Row::new(vec![ + Cell::from(entry.key.clone()), + usage_cell(entry.value, total), + Cell::from(format!("{:.1}", entry.value.pct_of(total))) + .style(Style::default().fg(usage_color(entry.value, total))), + ]) + .style(usage_style(false, entry.value, total)) +} + +fn summary_row(label: &str, value: Bytes, total: Bytes) -> Row<'static> { + Row::new(vec![ + Cell::from(label.to_string()), + usage_cell(value, total), + ]) + .style(usage_style(false, value, total)) +} + +fn summary_text_row(label: &str, value: &str) -> Row<'static> { + Row::new(vec![ + Cell::from(label.to_string()), + Cell::from(value.to_string()), + ]) +} + +fn row_process( + app: &App, + snapshot: &Snapshot, + row: &FlatProcessRow, + selected: bool, + capacity: Bytes, +) -> Row<'static> { + let node = &snapshot.process_tree.nodes[row.index]; + let rollup = app.process_scope.rollup(node); + let marker = match row.fold { + RowFold::Leaf => " ", + RowFold::Collapsed => "▸", + RowFold::Expanded => "▾", + }; + let name = format!("{}{} {}", " ".repeat(row.depth), marker, node.name); + Row::new(vec![ + Cell::from(name), + Cell::from(node.pid.to_string()), + Cell::from(node.username.clone()), + usage_cell(rollup.pss, capacity), + usage_cell(rollup.uss(), capacity), + usage_cell(rollup.rss, capacity), + Cell::from(node.command.clone()), + ]) + .style(usage_style_for_role( + selected, + rollup.metric(app.metric), + capacity, + row.search, + )) +} + +fn row_tmpfs(row: &FlatTmpfsRow, selected: bool, capacity: Bytes) -> Row<'static> { + let marker = match row.fold { + RowFold::Leaf => " ", + RowFold::Collapsed => "▸", + RowFold::Expanded => "▾", + }; + let label = format!("{}{} {}", " ".repeat(row.depth), marker, row.name); + Row::new(vec![ + Cell::from(label), + Cell::from(row.kind.label().to_string()), + usage_cell(row.allocated, capacity), + usage_cell(row.logical, capacity), + Cell::from(row.path.display().to_string()), + ]) + .style(usage_style_for_role( + selected, + row.allocated, + capacity, + row.search, + )) +} + +fn row_shared( + snapshot: &Snapshot, + row: &FlatSharedRow, + selected: bool, + capacity: Bytes, +) -> Row<'static> { + let object = &snapshot.shared_objects[row.index]; + Row::new(vec![ + Cell::from(object.kind.label().to_string()), + Cell::from(object.mapped_processes.to_string()), + usage_cell(object.rollup.pss, capacity), + usage_cell(object.rollup.rss, capacity), + Cell::from(object.regions.to_string()), + Cell::from(object.label.clone()), + ]) + .style(usage_style_for_role( + selected, + object.rollup.pss, + capacity, + row.search, + )) +} + +fn row_object_usage(object: &ObjectUsage, capacity: Bytes) -> Row<'static> { + Row::new(vec![ + Cell::from(object.kind.label().to_string()), + usage_cell(object.rollup.pss, capacity), + usage_cell(object.rollup.rss, capacity), + Cell::from(object.regions.to_string()), + Cell::from(object.label.clone()), + ]) + .style(usage_style(false, object.rollup.pss, capacity)) +} + +fn tmpfs_detail_lines(mount: &TmpfsMount, row: &FlatTmpfsRow) -> Vec> { + let mut lines = vec![ + Line::from(Span::styled( + row.path.display().to_string(), + Style::default().fg(ACCENT).add_modifier(Modifier::BOLD), + )), + detail_line("Mount", &mount.mount_point.display().to_string()), + detail_line("Source", &mount.source), + detail_line("Kind", row.kind.label()), + detail_line("Allocated", &row.allocated.human_exact()), + detail_line("Logical", &row.logical.human_exact()), + ]; + if let Some(limit) = mount.size_limit { + lines.push(detail_line("Mount size", &limit.human_exact())); + lines.push(detail_line( + "Utilization", + &format!("{:.1}%", row.allocated.pct_of(limit)), + )); + } + lines +} + +fn search_summary_lines(app: &App, capacity: Bytes) -> Vec> { + let Some(summary) = app.search_summary() else { + return Vec::new(); + }; + let pattern = app.search_pattern().unwrap_or_default(); + vec![ + Line::from(Span::styled( + "regexp matches", + Style::default().fg(GOLD).add_modifier(Modifier::BOLD), + )), + detail_line("regexp", &format!("/{pattern}/")), + detail_line("matches", &summary.matches.to_string()), + detail_line(summary.lens, &summary.total.human_exact()), + detail_line( + "pct total", + &format!("{:.2}%", summary.total.pct_of(capacity)), + ), + detail_line("mode", app.search_scope_label()), + Line::from(""), + ] +} + +fn detail_line(label: &str, value: &str) -> Line<'static> { + Line::from(vec![ + Span::styled(format!("{label:>12} "), Style::default().fg(MUTED)), + Span::styled(value.to_string(), Style::default().fg(FG)), + ]) +} + +fn header_row(values: [&str; N]) -> Row<'static> { + Row::new( + values + .into_iter() + .map(|value| Cell::from(value.to_string())), + ) + .style(Style::default().fg(GOLD).add_modifier(Modifier::BOLD)) +} + +fn usage_style(selected: bool, value: Bytes, total: Bytes) -> Style { + row_fg_style(selected, usage_color(value, total)) +} + +fn usage_style_for_role(selected: bool, value: Bytes, total: Bytes, role: SearchRole) -> Style { + if role.is_context() && !selected { + Style::default().fg(MUTED) + } else { + usage_style(selected, value, total) + } +} + +fn row_fg_style(selected: bool, fg: Color) -> Style { + if selected { + Style::default() + .fg(fg) + .bg(Color::Rgb(28, 44, 61)) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(fg) + } +} + +fn usage_cell(value: Bytes, total: Bytes) -> Cell<'static> { + Cell::from(value.human_iec()).style(Style::default().fg(usage_color(value, total))) +} + +fn usage_color(value: Bytes, total: Bytes) -> Color { + if value.0 == 0 || total.0 == 0 { + return Color::Rgb(105, 113, 121); + } + + let pct = (value.as_f64() / total.as_f64()).clamp(0.0, 1.0); + if pct <= 0.03 { + return blend_rgb((105, 113, 121), (246, 248, 250), pct / 0.03); + } + blend_rgb((246, 248, 250), (232, 58, 46), (pct - 0.03) / 0.97) +} + +fn blend_rgb(start: (u8, u8, u8), end: (u8, u8, u8), t: f64) -> Color { + Color::Rgb( + blend_channel(start.0, end.0, t), + blend_channel(start.1, end.1, t), + blend_channel(start.2, end.2, t), + ) +} + +fn blend_channel(start: u8, end: u8, t: f64) -> u8 { + (f64::from(start) + (f64::from(end) - f64::from(start)) * t) + .round() + .clamp(0.0, 255.0) as u8 +} + +fn panel(title: &str) -> Block<'static> { + Block::default() + .borders(Borders::ALL) + .title(title.to_string()) + .style(Style::default().fg(FG).bg(BG)) +} + +fn render_help(frame: &mut Frame<'_>, app: &App, area: Rect) { + let popup = centered_rect(area, 82, 88); + frame.render_widget(Clear, popup); + let hotkeys = app.hotkey_sections(); + let mut text = vec![ + Line::from(Span::styled( + "memview keys", + Style::default().fg(ACCENT).add_modifier(Modifier::BOLD), + )), + Line::from(""), + section_heading("Global"), + ]; + text.extend(hotkeys.global.iter().map(hotkey_line)); + text.extend([ + Line::from(""), + section_heading(&format!("Pane: {}", hotkeys.pane_title)), + ]); + text.extend(hotkeys.pane.iter().map(hotkey_line)); + text.extend([ + Line::from(""), + section_heading("Notes"), + Line::from("Overview shows raw kernel counters and the main reconciliation lenses."), + Line::from("Processes uses PSS so shared pages are not double-counted."), + Line::from( + "Tmpfs uses allocated blocks, which is closer to actual backing than file length.", + ), + Line::from("Tree panes auto-fold subtrees below min(1% RAM, 3% largest non-root subtree)."), + Line::from( + "Shared aggregates mapped objects across tasks: tmpfs, memfd, SYSV, files, and anon.", + ), + ]); + frame.render_widget( + Paragraph::new(text) + .block(panel("Help")) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(FG)), + popup, + ); +} + +fn section_heading(label: &str) -> Line<'static> { + Line::from(Span::styled( + label.to_string(), + Style::default().fg(GOLD).add_modifier(Modifier::BOLD), + )) +} + +fn hotkey_line(hotkey: &Hotkey) -> Line<'static> { + detail_line(hotkey.key, hotkey.action) +} + +fn render_kill_confirmation(frame: &mut Frame<'_>, app: &App, area: Rect) { + let Some(confirmation) = app.kill_confirmation.as_ref() else { + return; + }; + + let popup = centered_rect(area, 88, 88); + frame.render_widget(Clear, popup); + let block = panel("kill"); + let inner = block.inner(popup); + frame.render_widget(block, popup); + + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(4), + Constraint::Min(1), + Constraint::Length(3), + ]) + .split(inner); + + let top = vec![ + Line::from(Span::styled( + "Send SIGTERM to this process?", + Style::default().fg(HOT).add_modifier(Modifier::BOLD), + )), + Line::from(""), + detail_line("PID", &confirmation.target.pid.to_string()), + detail_line("Name", &confirmation.target.name), + ]; + frame.render_widget( + Paragraph::new(top).style(Style::default().fg(FG)), + sections[0], + ); + + frame.render_widget( + Paragraph::new(confirmation.target.cli().to_string()) + .block( + Block::default() + .borders(Borders::TOP | Borders::BOTTOM) + .title("Full CLI") + .style(Style::default().fg(ACCENT).bg(BG)), + ) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(FG).bg(BG)), + sections[1], + ); + + let mut controls = Vec::new(); + if confirmation.armed() { + controls.push(Line::from(Span::styled( + "press y to send SIGTERM", + Style::default().fg(HOT).add_modifier(Modifier::BOLD), + ))); + } else { + controls.push(detail_line( + "lockout", + &format!( + "{} ms before y is accepted", + confirmation.lock_remaining().as_millis() + ), + )); + } + controls.push(detail_line("cancel", "Esc or n")); + + frame.render_widget( + Paragraph::new(controls) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(FG).bg(BG)), + sections[2], + ); +} + +fn render_search_prompt(frame: &mut Frame<'_>, app: &App, area: Rect) { + let Some(draft) = app.search_draft() else { + return; + }; + + let popup = centered_rect(area, 76, 22); + frame.render_widget(Clear, popup); + let mut lines = vec![ + Line::from(Span::styled( + "Filter rows by regexp", + Style::default().fg(ACCENT).add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(vec![ + Span::styled("/", Style::default().fg(GOLD)), + Span::styled(draft.input().to_string(), Style::default().fg(FG)), + ]), + Line::from(""), + detail_line("accept", "Enter"), + detail_line("clear", "empty input, then Enter"), + detail_line("cancel", "Esc"), + ]; + if let Some(error) = draft.error() { + lines.push(detail_line("error", error)); + } + frame.render_widget( + Paragraph::new(lines) + .block(panel("search")) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(FG)), + popup, + ); +} + +fn centered_rect(area: Rect, width_pct: u16, height_pct: u16) -> Rect { + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - height_pct) / 2), + Constraint::Percentage(height_pct), + Constraint::Percentage((100 - height_pct) / 2), + ]) + .split(area); + let horizontal = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - width_pct) / 2), + Constraint::Percentage(width_pct), + Constraint::Percentage((100 - width_pct) / 2), + ]) + .split(vertical[1]); + horizontal[1] +} + +struct SliceWindow<'a, T> { + start: usize, + slice: &'a [T], +} + +impl std::ops::Deref for SliceWindow<'_, T> { + type Target = [T]; + + fn deref(&self) -> &Self::Target { + self.slice + } +} + +fn slice_window(items: &[T], selected: usize, height: usize) -> SliceWindow<'_, T> { + if items.is_empty() { + return SliceWindow { + start: 0, + slice: items, + }; + } + let height = height.max(1); + let half = height / 2; + let start = selected + .saturating_sub(half) + .min(items.len().saturating_sub(height)); + let end = (start + height).min(items.len()); + SliceWindow { + start, + slice: &items[start..end], + } +} -- cgit v1.2.3