Skip to main content

monitrs_core/model/
process.rs

1//! Per-process metrics.
2//!
3//! Two structures exist on purpose. [`ProcessSnapshot`] is what the fast tier
4//! collects for *every* process every tick, so it holds only cheap fields.
5//! [`ProcessDetail`] is collected on demand for the *selected* process only,
6//! because §2.4 forbids paying for expensive per-process reads across the whole
7//! table on every tick.
8
9use core::time::Duration;
10use std::time::SystemTime;
11
12use crate::model::{MetricState, ProcessIdentity, UserIdentity};
13use crate::units::{Percent, Rate};
14
15/// The kernel scheduling state of a process.
16#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
17#[cfg_attr(feature = "serde", derive(serde::Serialize))]
18#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
19pub enum ProcessState {
20    /// Running or runnable.
21    Running,
22    /// Interruptible sleep, the normal idle state.
23    Sleeping,
24    /// Uninterruptible sleep, usually blocked in the kernel on I/O.
25    ///
26    /// §7.2 requires this to be visibly distinct: an accumulation of `D`-state
27    /// processes is a storage or NFS problem, not idleness.
28    UninterruptibleSleep,
29    /// Exited but not reaped by its parent.
30    ///
31    /// §7.2 requires this to be visibly distinct, and §11.2 has a rule for it.
32    Zombie,
33    /// Stopped by a job-control signal.
34    Stopped,
35    /// Stopped by a debugger.
36    Traced,
37    /// Idle kernel thread. Linux `I`, and the state macOS reports for a
38    /// suspended process.
39    Idle,
40    /// Being torn down.
41    Dead,
42    /// The platform reported something we do not model.
43    #[default]
44    Unknown,
45}
46
47impl ProcessState {
48    /// The single-character code shown in the `STATE` column.
49    ///
50    /// Deliberately the familiar `ps` letters, so existing knowledge transfers.
51    #[must_use]
52    pub const fn code(self) -> char {
53        match self {
54            Self::Running => 'R',
55            Self::Sleeping => 'S',
56            Self::UninterruptibleSleep => 'D',
57            Self::Zombie => 'Z',
58            Self::Stopped => 'T',
59            Self::Traced => 't',
60            Self::Idle => 'I',
61            Self::Dead => 'X',
62            Self::Unknown => '?',
63        }
64    }
65
66    /// A spelled-out label for the detail overlay and help.
67    #[must_use]
68    pub const fn label(self) -> &'static str {
69        match self {
70            Self::Running => "running",
71            Self::Sleeping => "sleeping",
72            Self::UninterruptibleSleep => "uninterruptible sleep",
73            Self::Zombie => "zombie",
74            Self::Stopped => "stopped",
75            Self::Traced => "traced",
76            Self::Idle => "idle",
77            Self::Dead => "dead",
78            Self::Unknown => "unknown",
79        }
80    }
81
82    /// Whether §7.2 requires this state to be rendered distinctly.
83    #[must_use]
84    pub const fn is_notable(self) -> bool {
85        matches!(self, Self::Zombie | Self::UninterruptibleSleep)
86    }
87
88    /// Whether signalling this process can have any effect.
89    ///
90    /// A zombie has already exited; signalling it is a no-op and the
91    /// confirmation dialog says so rather than pretending to act (§15.1).
92    #[must_use]
93    pub const fn is_signalable(self) -> bool {
94        !matches!(self, Self::Zombie | Self::Dead)
95    }
96}
97
98/// Per-process memory figures.
99#[derive(Clone, Copy, Debug, PartialEq)]
100#[cfg_attr(feature = "serde", derive(serde::Serialize))]
101pub struct ProcessMemory {
102    /// Resident set size: physical memory currently mapped in.
103    pub rss_bytes: MetricState<u64>,
104    /// Virtual size: address space reserved, most of it never resident.
105    ///
106    /// Lowest column priority in §7.2 precisely because it is the most
107    /// frequently misread number in a process table.
108    pub virtual_bytes: MetricState<u64>,
109    /// RSS as a share of total physical memory.
110    pub share_of_total: MetricState<Percent>,
111}
112
113impl ProcessMemory {
114    /// A value with nothing measured.
115    pub const WARMING_UP: Self = Self {
116        rss_bytes: MetricState::WarmingUp,
117        virtual_bytes: MetricState::WarmingUp,
118        share_of_total: MetricState::WarmingUp,
119    };
120}
121
122/// Per-process disk I/O.
123///
124/// Requires `/proc/<pid>/io` on Linux (often permission-restricted for other
125/// users' processes) and privileged access on macOS, so it is frequently
126/// [`MetricState::PermissionDenied`] rather than zero.
127#[derive(Clone, Copy, Debug, PartialEq)]
128#[cfg_attr(feature = "serde", derive(serde::Serialize))]
129pub struct ProcessIo {
130    /// Read throughput.
131    pub read: MetricState<Rate>,
132    /// Write throughput.
133    pub write: MetricState<Rate>,
134    /// Cumulative bytes read since the process started.
135    pub read_total_bytes: MetricState<u64>,
136    /// Cumulative bytes written since the process started.
137    pub write_total_bytes: MetricState<u64>,
138}
139
140impl ProcessIo {
141    /// A value for a platform that cannot report per-process I/O at all.
142    pub const UNSUPPORTED: Self = Self {
143        read: MetricState::Unsupported,
144        write: MetricState::Unsupported,
145        read_total_bytes: MetricState::Unsupported,
146        write_total_bytes: MetricState::Unsupported,
147    };
148
149    /// A value whose counters exist but whose rates need a second sample.
150    pub const WARMING_UP: Self = Self {
151        read: MetricState::WarmingUp,
152        write: MetricState::WarmingUp,
153        read_total_bytes: MetricState::WarmingUp,
154        write_total_bytes: MetricState::WarmingUp,
155    };
156}
157
158/// The cheap per-process fields collected on every fast tick.
159#[derive(Clone, Debug, PartialEq)]
160#[cfg_attr(feature = "serde", derive(serde::Serialize))]
161pub struct ProcessSnapshot {
162    /// Stable identity, safe to pin and to attach to a pending signal.
163    pub identity: ProcessIdentity,
164    /// Parent PID.
165    ///
166    /// A bare PID rather than a [`ProcessIdentity`] because the parent's start
167    /// key is not available from the same read; tree construction resolves it
168    /// against the rest of the snapshot.
169    pub parent_pid: Option<u32>,
170    /// Short process name, as the kernel reports it.
171    pub name: Box<str>,
172    /// Full command line, arguments joined by single spaces.
173    ///
174    /// Stored pre-joined to avoid a `Vec<String>` per process per tick. May be
175    /// empty for kernel threads or another user's process, and §14.2 requires it
176    /// to be redacted from logs because arguments can contain secrets.
177    pub command: Box<str>,
178    /// Executable path, where readable.
179    pub exe: Option<Box<str>>,
180    /// Owning user.
181    pub user: MetricState<UserIdentity>,
182    /// Scheduling state.
183    pub state: ProcessState,
184    /// CPU usage, core-normalized. May exceed 100% (§8.3).
185    pub cpu: MetricState<Percent>,
186    /// Memory figures.
187    pub memory: ProcessMemory,
188    /// Disk I/O.
189    pub io: ProcessIo,
190    /// Thread count, where reported.
191    pub threads: MetricState<u32>,
192    /// Time since the process started.
193    pub age: MetricState<Duration>,
194    /// Wall-clock start time, for the confirmation dialog (§6.2).
195    pub started_at: MetricState<SystemTime>,
196    /// Whether this is a kernel thread, which §7.2 allows hiding on Linux.
197    pub is_kernel_thread: bool,
198}
199
200impl ProcessSnapshot {
201    /// The command line if non-empty, otherwise the process name.
202    ///
203    /// Kernel threads and processes belonging to other users report no command
204    /// line; showing an empty cell would look like a collection bug.
205    #[must_use]
206    pub fn command_or_name(&self) -> &str {
207        if self.command.is_empty() {
208            &self.name
209        } else {
210            &self.command
211        }
212    }
213
214    /// The command line with arguments removed, keeping only `argv[0]`.
215    ///
216    /// §15.2 requires JSON export to be able to redact arguments by default,
217    /// and §14.2 requires the same for logs.
218    #[must_use]
219    pub fn redacted_command(&self) -> &str {
220        let command = self.command_or_name();
221        match command.split_once(' ') {
222            Some((program, _)) => program,
223            None => command,
224        }
225    }
226}
227
228/// One entry in a process's ancestry chain.
229#[derive(Clone, Debug, PartialEq)]
230#[cfg_attr(feature = "serde", derive(serde::Serialize))]
231pub struct AncestorEntry {
232    /// The ancestor's identity.
233    pub identity: ProcessIdentity,
234    /// The ancestor's short name, for the breadcrumb (§2.4).
235    pub name: Box<str>,
236}
237
238/// The expensive per-process fields, collected on demand for the selected
239/// process only (§8.6).
240///
241/// Environment variables are deliberately absent from this type, not merely
242/// hidden by default: §7.5 forbids showing their values, and §15.2 forbids
243/// logging them, so the safest design is never to read them at all.
244#[derive(Clone, Debug, PartialEq)]
245#[cfg_attr(feature = "serde", derive(serde::Serialize))]
246pub struct ProcessDetail {
247    /// Which process this describes.
248    ///
249    /// Checked against the current selection before rendering, so a late reply
250    /// for a process the user has moved off is discarded rather than shown
251    /// against the wrong row.
252    pub identity: ProcessIdentity,
253    /// Current working directory.
254    pub working_directory: MetricState<Box<str>>,
255    /// Filesystem root, which differs from `/` inside a container.
256    pub root: MetricState<Box<str>>,
257    /// Open file descriptor count.
258    pub open_files: MetricState<u32>,
259    /// Open socket count.
260    pub sockets: MetricState<u32>,
261    /// Ancestry from the immediate parent up towards PID 1.
262    pub ancestry: MetricState<Vec<AncestorEntry>>,
263    /// Direct children.
264    pub children: MetricState<Vec<ProcessIdentity>>,
265    /// Total descendants, including indirect ones (§2.4).
266    pub descendants: MetricState<u32>,
267    /// Scheduling niceness.
268    pub nice: MetricState<i32>,
269    /// cgroup path. Linux only.
270    pub cgroup: MetricState<Box<str>>,
271    /// Container identity, where derivable from the cgroup path.
272    pub container: MetricState<Box<str>>,
273    /// When this detail was collected, so the UI can age it.
274    pub collected_at: SystemTime,
275}
276
277impl ProcessDetail {
278    /// An empty detail record for `identity`, with every field unmeasured.
279    #[must_use]
280    pub fn pending(identity: ProcessIdentity, collected_at: SystemTime) -> Self {
281        Self {
282            identity,
283            working_directory: MetricState::WarmingUp,
284            root: MetricState::WarmingUp,
285            open_files: MetricState::WarmingUp,
286            sockets: MetricState::WarmingUp,
287            ancestry: MetricState::WarmingUp,
288            children: MetricState::WarmingUp,
289            descendants: MetricState::WarmingUp,
290            nice: MetricState::WarmingUp,
291            cgroup: MetricState::WarmingUp,
292            container: MetricState::WarmingUp,
293            collected_at,
294        }
295    }
296}
297
298/// The outcome of a detail lookup, which may fail because the process exited.
299#[derive(Clone, Debug, PartialEq)]
300pub enum ProcessDetailResult {
301    /// The lookup succeeded, at least partially.
302    Loaded(Box<ProcessDetail>),
303    /// The process no longer exists.
304    ///
305    /// Expected and not an error (§14.1).
306    Vanished(ProcessIdentity),
307    /// The PID now belongs to a different process.
308    Reused {
309        /// What was requested.
310        requested: ProcessIdentity,
311        /// What the PID refers to now.
312        found: ProcessIdentity,
313    },
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    fn sample(command: &str, name: &str) -> ProcessSnapshot {
321        ProcessSnapshot {
322            identity: ProcessIdentity::new(31_842, 900_100),
323            parent_pid: Some(1),
324            name: name.into(),
325            command: command.into(),
326            exe: None,
327            user: MetricState::Unsupported,
328            state: ProcessState::Running,
329            cpu: MetricState::WarmingUp,
330            memory: ProcessMemory::WARMING_UP,
331            io: ProcessIo::UNSUPPORTED,
332            threads: MetricState::Unsupported,
333            age: MetricState::Unsupported,
334            started_at: MetricState::Unsupported,
335            is_kernel_thread: false,
336        }
337    }
338
339    #[test]
340    fn notable_states_are_exactly_zombie_and_uninterruptible_sleep() {
341        assert!(ProcessState::Zombie.is_notable());
342        assert!(ProcessState::UninterruptibleSleep.is_notable());
343        for state in [
344            ProcessState::Running,
345            ProcessState::Sleeping,
346            ProcessState::Stopped,
347            ProcessState::Traced,
348            ProcessState::Idle,
349            ProcessState::Dead,
350            ProcessState::Unknown,
351        ] {
352            assert!(!state.is_notable(), "{state:?}");
353        }
354    }
355
356    #[test]
357    fn state_codes_are_unique_so_the_column_is_unambiguous() {
358        let states = [
359            ProcessState::Running,
360            ProcessState::Sleeping,
361            ProcessState::UninterruptibleSleep,
362            ProcessState::Zombie,
363            ProcessState::Stopped,
364            ProcessState::Traced,
365            ProcessState::Idle,
366            ProcessState::Dead,
367            ProcessState::Unknown,
368        ];
369        let mut codes: Vec<char> = states.iter().map(|s| s.code()).collect();
370        codes.sort_unstable();
371        codes.dedup();
372        assert_eq!(codes.len(), states.len());
373    }
374
375    #[test]
376    fn already_exited_processes_are_not_signalable() {
377        assert!(!ProcessState::Zombie.is_signalable());
378        assert!(!ProcessState::Dead.is_signalable());
379        assert!(ProcessState::Running.is_signalable());
380        assert!(ProcessState::UninterruptibleSleep.is_signalable());
381    }
382
383    #[test]
384    fn an_empty_command_falls_back_to_the_process_name() {
385        let kernel_thread = sample("", "kworker/2:1");
386        assert_eq!(kernel_thread.command_or_name(), "kworker/2:1");
387        let normal = sample("cargo build --release", "cargo");
388        assert_eq!(normal.command_or_name(), "cargo build --release");
389    }
390
391    #[test]
392    fn redaction_strips_arguments_which_may_contain_secrets() {
393        let process = sample("psql postgres://user:hunter2@db/prod", "psql");
394        assert_eq!(process.redacted_command(), "psql");
395        assert!(!process.redacted_command().contains("hunter2"));
396    }
397
398    #[test]
399    fn redaction_of_a_bare_program_keeps_the_program() {
400        assert_eq!(sample("rustc", "rustc").redacted_command(), "rustc");
401        assert_eq!(sample("", "kworker/2:1").redacted_command(), "kworker/2:1");
402    }
403
404    #[test]
405    fn a_pending_detail_reports_nothing_as_measured() {
406        let identity = ProcessIdentity::new(1, 2);
407        let detail = ProcessDetail::pending(identity, SystemTime::UNIX_EPOCH);
408        assert_eq!(detail.identity, identity);
409        assert!(detail.working_directory.is_warming_up());
410        assert!(detail.open_files.fresh().is_none());
411    }
412}