swarm repositories / source
aboutsummaryrefslogtreecommitdiff
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to 'crates')
-rw-r--r--crates/memview/Cargo.toml28
-rw-r--r--crates/memview/README.md23
-rw-r--r--crates/memview/src/app.rs1774
-rw-r--r--crates/memview/src/app/rows.rs484
-rw-r--r--crates/memview/src/app/tests.rs390
-rw-r--r--crates/memview/src/app/worker.rs368
-rw-r--r--crates/memview/src/main.rs160
-rw-r--r--crates/memview/src/model.rs480
-rw-r--r--crates/memview/src/nav.rs217
-rw-r--r--crates/memview/src/probe.rs1417
-rw-r--r--crates/memview/src/search.rs130
-rw-r--r--crates/memview/src/ui.rs1076
12 files changed, 6547 insertions, 0 deletions
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<Row: IdentifiedRow> {
+ rows: Vec<Row>,
+ by_key: BTreeMap<Row::Key, RowIndex>,
+ selected: Option<RowIndex>,
+}
+
+impl<Row: IdentifiedRow> Default for PaneRows<Row> {
+ fn default() -> Self {
+ Self {
+ rows: Vec::new(),
+ by_key: BTreeMap::new(),
+ selected: None,
+ }
+ }
+}
+
+impl<Row: IdentifiedRow> PaneRows<Row> {
+ #[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<RowIndex> {
+ self.selected
+ }
+
+ fn selected_key(&self) -> Option<Row::Key> {
+ self.selected().map(|row| row.key().clone())
+ }
+
+ fn install(&mut self, rows: Vec<Row>) {
+ self.install_prefer(rows, self.selected_key());
+ }
+
+ fn install_pinned_to_top(&mut self, rows: Vec<Row>) {
+ self.install_prefer(rows, None);
+ }
+
+ fn install_prefer(&mut self, rows: Vec<Row>, preferred: Option<Row::Key>) {
+ let by_key = rows
+ .iter()
+ .enumerate()
+ .map(|(index, row)| (row.key().clone(), RowIndex::new(index)))
+ .collect::<BTreeMap<_, _>>();
+ 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<RowIndex> {
+ 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<Key>,
+ expanded: &'a BTreeSet<Key>,
+ de_minimis: DeMinimis,
+}
+
+impl<Key: Ord> 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<char> {
+ 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<Snapshot>,
+ pub last_error: Option<String>,
+ pub process_scan_started_at: Option<Instant>,
+ search: Option<Search>,
+ search_draft: Option<SearchDraft>,
+ process_search: SearchSummary,
+ tmpfs_search: SearchSummary,
+ shared_search: SearchSummary,
+ collapsed_processes: BTreeSet<Pid>,
+ expanded_processes: BTreeSet<Pid>,
+ collapsed_tmpfs: BTreeSet<PathBuf>,
+ expanded_tmpfs: BTreeSet<PathBuf>,
+ process_mapping_cache: BTreeMap<Pid, ProcessMappingLedger>,
+ process_mapping_started_at: Option<(Pid, Instant)>,
+ shared_scan_started_at: Option<Instant>,
+ pub deletions: Vec<DeleteTask>,
+ confirmed_deletions: BTreeSet<PathBuf>,
+ pub kill_confirmation: Option<KillConfirmation>,
+ inventory_warnings: Vec<String>,
+ tmpfs_refresh_warnings: Vec<String>,
+ process_warnings: Vec<String>,
+ pending_process_scan: Option<probe::ProcessScan>,
+ tmpfs_selection: SelectionCustody,
+ process_rows: PaneRows<FlatProcessRow>,
+ tmpfs_rows: PaneRows<FlatTmpfsRow>,
+ shared_rows: PaneRows<FlatSharedRow>,
+ 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<Self, String> {
+ 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<ObjectUsage>,
+ 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<DeleteOutcome>,
+}
+
+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<WorkerCommand>) {
+ 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<WorkerCommand>) {
+ if self.focused == focused {
+ return;
+ }
+ self.focused = focused;
+ self.sync_process_scanning(commands);
+ }
+
+ pub fn apply_worker_event(&mut self, event: WorkerEvent, commands: &Sender<WorkerCommand>) {
+ 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<Box<Snapshot>>) {
+ 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<Box<probe::TmpfsMountScan>>) {
+ 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<Box<probe::ProcessScan>>,
+ commands: &Sender<WorkerCommand>,
+ ) {
+ 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<Box<probe::ProcessMappingScan>>) {
+ 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<Box<probe::SharedObjectsScan>>) {
+ 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<PathBuf> {
+ 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<WorkerCommand>) -> 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<String> {
+ 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<Instant> {
+ 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<WorkerCommand>) -> 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<WorkerCommand>) -> 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<WorkerCommand>,
+ ) {
+ 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<WorkerCommand>) -> 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<WorkerCommand>) {
+ 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<WorkerCommand>) {
+ let _ = commands.send(WorkerCommand::SetProcessScanning(
+ self.focused && self.tab.drives_process_scans(),
+ ));
+ }
+
+ fn request_current_pane(&mut self, commands: &Sender<WorkerCommand>) {
+ 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<WorkerCommand>) {
+ 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::<BTreeSet<_>>();
+ 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<WorkerCommand>) {
+ 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<WorkerCommand>,
+ ) -> 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<WorkerCommand>,
+ ) -> 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<WorkerCommand>,
+ ) -> 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<WorkerCommand>,
+ ) -> 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<WorkerCommand>,
+ ) -> 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<WorkerCommand>) {
+ 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<OwnedFd, String> {
+ 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<PathBuf>) {
+ 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<PathBuf>) -> 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<PathBuf>) -> 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<Pid>,
+ expanded: &BTreeSet<Pid>,
+ search: Option<&Search>,
+) -> (Vec<FlatProcessRow>, 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<PathBuf>,
+ expanded: &BTreeSet<PathBuf>,
+ search: Option<&Search>,
+) -> (Vec<FlatTmpfsRow>, 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<FlatSharedRow>, 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<usize> {
+ 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<FlatProcessRow>,
+) {
+ 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<usize> {
+ 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<FlatProcessRow>,
+ 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<FlatProcessRow>,
+ 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<FlatTmpfsRow>,
+) {
+ 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<FlatTmpfsRow>,
+ 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<FlatTmpfsRow>,
+ 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::<Vec<_>>();
+ 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<TmpfsNode>) -> 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 {
+ TmpfsNode {
+ path: PathBuf::from(path),
+ name: path.to_string(),
+ kind: TmpfsNodeKind::Directory,
+ allocated,
+ logical: allocated,
+ children,
+ }
+}
+
+fn tmpfs_snapshot(mounts: Vec<TmpfsMount>) -> 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<WorkerCommand>) -> 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<_>>(),
+ 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<_>>(),
+ 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<_>>(),
+ 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<Box<Snapshot>>),
+ TmpfsMountReady(Result<Box<probe::TmpfsMountScan>>),
+ ProcessesStarted(Instant),
+ ProcessesReady(Result<Box<probe::ProcessScan>>),
+ ProcessMappingsReady(Result<Box<probe::ProcessMappingScan>>),
+ SharedObjectsStarted(Instant),
+ SharedObjectsReady(Result<Box<probe::SharedObjectsScan>>),
+}
+
+pub fn spawn_worker(refresh_every: Duration) -> (Sender<WorkerCommand>, Receiver<WorkerEvent>) {
+ 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<WorkerCommand>, event_tx: Sender<WorkerEvent>) {
+ 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<Pid>,
+ 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<WorkerCommand>,
+ active: &mut bool,
+ scan_once: &mut bool,
+ mapping_request: &mut Option<Pid>,
+ 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<WorkerEvent>, 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<WorkerEvent>) -> 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<WorkerEvent>) -> 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<WorkerCommand>,
+ refresh_every: Duration,
+ active: &mut bool,
+ scan_once: &mut bool,
+ mapping_request: &mut Option<Pid>,
+ 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<WorkerEvent>) -> 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<WorkerEvent>) -> 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<WorkerEvent>, 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<WorkerCommand>,
+ event_tx: Sender<WorkerEvent>,
+) {
+ 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<CrosstermBackend<Stdout>>,
+}
+
+impl TerminalGuard {
+ fn enter() -> Result<Self> {
+ 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<F>(&mut self, draw: F) -> Result<()>
+ where
+ F: FnOnce(&mut ratatui::Frame<'_>),
+ {
+ let _ = self.terminal.draw(draw)?;
+ Ok(())
+ }
+
+ fn height(&self) -> Result<u16> {
+ 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<MeminfoEntry>,
+ pub table: BTreeMap<String, Bytes>,
+}
+
+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<ObjectConsumer>,
+}
+
+#[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<Pid>,
+ pub name: String,
+ pub command: String,
+ pub username: String,
+ pub state: String,
+ pub threads: u32,
+ pub rollup: MemoryRollup,
+ pub subtree: MemoryRollup,
+ pub children: Vec<usize>,
+ pub objects: Vec<ObjectUsage>,
+ 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<usize>,
+ pub nodes: Vec<ProcessNode>,
+ 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<TmpfsNode>,
+}
+
+#[derive(Clone, Debug)]
+pub struct TmpfsMount {
+ pub mount_point: PathBuf,
+ pub source: String,
+ pub size_limit: Option<Bytes>,
+ 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<SharedObject>,
+ pub sysv_segments: Vec<SysvSegment>,
+ pub tmpfs_mounts: Vec<TmpfsMount>,
+ pub warnings: Vec<String>,
+}
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<TmpfsMount>,
+ sysv_segments: Vec<SysvSegment>,
+ warnings: Vec<String>,
+}
+
+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<String>,
+}
+
+#[derive(Debug)]
+pub struct ProcessMappingScan {
+ pub elapsed: Duration,
+ pub cost: ProcessMappingCost,
+ pub pid: Pid,
+ pub objects: Vec<ObjectUsage>,
+ pub mappings_state: LedgerState,
+ pub warnings: Vec<String>,
+}
+
+#[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<SharedObject>,
+ pub warnings: Vec<String>,
+}
+
+#[derive(Debug)]
+pub struct TmpfsMountScan {
+ pub captured_at: SystemTime,
+ pub elapsed: Duration,
+ pub mount: TmpfsMount,
+ pub warnings: Vec<String>,
+}
+
+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<Capture> {
+ 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<ProcessScan> {
+ 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<ProcessMappingScan> {
+ 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<SharedObjectsScan> {
+ 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<Vec<PathBuf>> {
+ 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<TmpfsMountScan> {
+ 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<MountInfo>,
+}
+
+#[derive(Clone, Debug)]
+struct TmpfsBuilder {
+ path: PathBuf,
+ name: String,
+ kind: TmpfsNodeKind,
+ own_allocated: Bytes,
+ own_logical: Bytes,
+ allocated: Bytes,
+ logical: Bytes,
+ children: Vec<PathBuf>,
+}
+
+impl MountIndex {
+ fn read() -> Result<Self> {
+ 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::<Vec<_>>();
+ 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<MountInfo> {
+ let (left, right) = line.split_once(" - ")?;
+ let left_fields = left.split_whitespace().collect::<Vec<_>>();
+ let right_fields = right.split_whitespace().collect::<Vec<_>>();
+ 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<Meminfo> {
+ 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::<Vec<_>>();
+ 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<String>) -> Result<Vec<SysvSegment>> {
+ 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::<Vec<_>>();
+ let index = columns
+ .iter()
+ .enumerate()
+ .map(|(position, name)| (*name, position))
+ .collect::<HashMap<_, _>>();
+
+ let mut segments = Vec::new();
+ for line in lines.filter(|line| !line.trim().is_empty()) {
+ let fields = line.split_whitespace().collect::<Vec<_>>();
+ let Some(id) = parse_column::<i32>(&fields, &index, "shmid") else {
+ warnings.push(format!("ignoring malformed sysv shm row: {line}"));
+ continue;
+ };
+
+ segments.push(SysvSegment {
+ id,
+ attachments: parse_column::<u32>(&fields, &index, "nattch").unwrap_or(0),
+ owner_uid: parse_column::<u32>(&fields, &index, "uid").unwrap_or(0),
+ size: parse_column::<u64>(&fields, &index, "size")
+ .map(Bytes)
+ .unwrap_or(Bytes::ZERO),
+ rss: parse_column::<u64>(&fields, &index, "rss")
+ .map(Bytes)
+ .unwrap_or(Bytes::ZERO),
+ swap: parse_column::<u64>(&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<T: std::str::FromStr>(
+ fields: &[&str],
+ index: &HashMap<&str, usize>,
+ name: &str,
+) -> Option<T> {
+ let position = *index.get(name)?;
+ fields.get(position)?.parse().ok()
+}
+
+#[derive(Clone, Debug)]
+struct ScannedProcess {
+ pid: Pid,
+ ppid: Option<Pid>,
+ name: String,
+ command: String,
+ username: String,
+ state: String,
+ threads: u32,
+ rollup: MemoryRollup,
+ objects: Vec<ObjectUsage>,
+ rollup_state: LedgerState,
+ mappings_state: LedgerState,
+}
+
+fn scan_process_shells(warnings: &mut Vec<String>) -> Result<Vec<ScannedProcess>> {
+ 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::<i32>() 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<String>) -> Result<ProcessForest> {
+ 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<u32, String>,
+) -> Result<Option<ScannedProcess>> {
+ 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<ScannedProcess>,
+ 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<Pid>,
+ 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::<i32>().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<Bytes> {
+ value
+ .split_whitespace()
+ .next()
+ .and_then(|field| field.parse::<u64>().ok())
+ .map(Bytes::from_kib)
+}
+
+fn read_cmdline(root: &Path) -> Option<String> {
+ 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::<Vec<_>>();
+ if parts.is_empty() {
+ None
+ } else {
+ Some(parts.join(" "))
+ }
+}
+
+fn lookup_username(uid: u32, cache: &mut BTreeMap<u32, String>) -> 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::<u64>().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<ObjectUsage> {
+ let mut objects = BTreeMap::<(ObjectKind, String), ObjectUsage>::new();
+ let mut current = None::<MappingAccumulator>;
+
+ 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::<Vec<_>>();
+ 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<MappingAccumulator>,
+ 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<MappingHeader> {
+ 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: "<anonymous>".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<ScannedProcess>, 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::<Vec<_>>();
+
+ let by_pid = nodes
+ .iter()
+ .enumerate()
+ .map(|(index, node)| (node.pid, index))
+ .collect::<BTreeMap<_, _>>();
+
+ 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<SharedObject> {
+ struct Accumulator {
+ kind: ObjectKind,
+ label: String,
+ rollup: MemoryRollup,
+ regions: usize,
+ consumers: Vec<ObjectConsumer>,
+ }
+
+ 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::<Vec<_>>();
+
+ 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<String>) -> Vec<MountInfo> {
+ let mut infos = mount_index.tmpfs.iter().collect::<Vec<_>>();
+ 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<TmpfsMount> {
+ 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::<PathBuf, TmpfsBuilder>::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::<Vec<_>>();
+ 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<PathBuf, TmpfsBuilder>) -> 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::<Vec<_>>();
+ 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<Bytes> {
+ options
+ .split(',')
+ .find_map(|option| option.strip_prefix("size=").and_then(parse_size_option))
+}
+
+fn parse_size_option(value: &str) -> Option<Bytes> {
+ let trimmed = value.trim();
+ let digits = trimmed
+ .chars()
+ .take_while(char::is_ascii_digit)
+ .collect::<String>();
+ let suffix = &trimmed[digits.len()..];
+ let number = digits.parse::<u64>().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<Option<Self>, 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<String>,
+}
+
+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::<Vec<_>>();
+ 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::<Vec<_>>()
+ };
+ 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::<Vec<_>>();
+ 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::<Vec<_>>();
+ 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::<Vec<_>>();
+ 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::<Vec<_>>();
+ 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::<Vec<_>>();
+ 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<Line<'static>> {
+ 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<Line<'static>> {
+ 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<const N: usize>(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<T> std::ops::Deref for SliceWindow<'_, T> {
+ type Target = [T];
+
+ fn deref(&self) -> &Self::Target {
+ self.slice
+ }
+}
+
+fn slice_window<T>(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],
+ }
+}