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/// What kind of object a file descriptor refers to.
229///
230/// The set is the intersection of what both platforms can name from a single read:
231/// macOS' `proc_fdtype` values and the prefix of a `/proc/<pid>/fd/<n>` link
232/// target. It exists because §4's "unavailable is never an empty string" needs an
233/// answer for a descriptor that has no path at all — a socket is not a nameless
234/// file, it is a socket, and the kind is what says so.
235#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
236#[cfg_attr(feature = "serde", derive(serde::Serialize))]
237#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
238pub enum OpenFileKind {
239    /// A file, directory, or device: something with a path in the filesystem.
240    File,
241    /// A socket. Has no filesystem path unless it is a bound Unix socket, and
242    /// neither platform reports that path through the descriptor table.
243    Socket,
244    /// A pipe or FIFO.
245    Pipe,
246    /// A kernel event queue: `kqueue` on macOS, `epoll`/`eventfd`/`timerfd` on
247    /// Linux.
248    EventQueue,
249    /// POSIX shared memory.
250    SharedMemory,
251    /// A POSIX semaphore.
252    Semaphore,
253    /// The platform reported something we do not model.
254    #[default]
255    Unknown,
256}
257
258impl OpenFileKind {
259    /// A lower-case label, in the same spirit as [`ProcessState::label`].
260    ///
261    /// Every label is strict 7-bit ASCII so it is legal in both glyph modes (§5.1).
262    #[must_use]
263    pub const fn label(self) -> &'static str {
264        match self {
265            Self::File => "file",
266            Self::Socket => "socket",
267            Self::Pipe => "pipe",
268            Self::EventQueue => "event queue",
269            Self::SharedMemory => "shared memory",
270            Self::Semaphore => "semaphore",
271            Self::Unknown => "unknown",
272        }
273    }
274
275    /// Whether a descriptor of this kind can have a filesystem path at all.
276    ///
277    /// The one thing that distinguishes "the OS refused the path" from "there is no
278    /// path to refuse": a collector that cannot resolve a [`Self::File`]'s path has
279    /// hit a permission or read failure, while a [`Self::Socket`]'s path is
280    /// [`MetricState::Unsupported`] on every platform and always will be.
281    #[must_use]
282    pub const fn has_path(self) -> bool {
283        matches!(self, Self::File)
284    }
285}
286
287/// One open file descriptor of a process.
288#[derive(Clone, Debug, Eq, PartialEq)]
289#[cfg_attr(feature = "serde", derive(serde::Serialize))]
290pub struct OpenFileEntry {
291    /// The descriptor number, as the process itself would use it.
292    pub descriptor: i32,
293    /// What the descriptor refers to.
294    pub kind: OpenFileKind,
295    /// Where it points in the filesystem.
296    ///
297    /// Never an empty string (§4). [`MetricState::Unsupported`] when
298    /// [`OpenFileKind::has_path`] is false, or when the kernel resolved the
299    /// descriptor but has no name for the object — an unlinked file has no path to
300    /// report and never will, so it is not a *temporary* failure either.
301    /// [`MetricState::PermissionDenied`] when the OS refused the per-descriptor
302    /// read, which is the common case for another user's process.
303    ///
304    /// Serialized as its state only, never as its text: see the private
305    /// `redact_descriptor_path` beside this type, and §15.2.
306    #[cfg_attr(feature = "serde", serde(serialize_with = "redact_descriptor_path"))]
307    pub path: MetricState<Box<str>>,
308}
309
310/// Serializes a descriptor path as its availability state, discarding the path.
311///
312/// §15.2 and §19 forbid a file path from leaving the process, and the JSON export
313/// already strips command arguments for the same reason: an export is something
314/// people paste into public issue trackers, and `/Users/someone/Documents/…` is as
315/// identifying as an argument list. Doing it in the `Serialize` implementation
316/// rather than in the exporter is what makes it unconditional — there is no
317/// `--include-paths` to add later by accident, and a future export that starts
318/// including [`ProcessDetail`] cannot leak paths by forgetting to.
319///
320/// The *state* survives, so a reader can still tell `permission denied` from
321/// `unsupported` — that is §4's information, not the user's.
322#[cfg(feature = "serde")]
323fn redact_descriptor_path<S: serde::Serializer>(
324    path: &MetricState<Box<str>>,
325    serializer: S,
326) -> Result<S::Ok, S::Error> {
327    use serde::Serialize as _;
328    path.as_ref().map(|_| "redacted").serialize(serializer)
329}
330
331/// The descriptors of one process, as far as the listing cap allowed.
332///
333/// The fields are private because the two of them have an invariant: `not_listed`
334/// is exactly the number of descriptors the cap left out, and a caller that could
335/// set them independently could claim a complete listing of a process it truncated.
336/// [`OpenFileList::listed`] is the only constructor, and it enforces both the cap
337/// and the arithmetic.
338#[derive(Clone, Debug, Default, Eq, PartialEq)]
339#[cfg_attr(feature = "serde", derive(serde::Serialize))]
340pub struct OpenFileList {
341    entries: Vec<OpenFileEntry>,
342    not_listed: u32,
343}
344
345impl OpenFileList {
346    /// The largest number of descriptors any collector will list paths for.
347    ///
348    /// Resolving a path costs one syscall per descriptor on both platforms — a
349    /// `proc_pidfdinfo` on macOS, a `readlink` on Linux — and a process can hold
350    /// tens of thousands, so §16.1's "nothing unbounded" applies even on the
351    /// on-demand tier of §8.6. 256 was measured at 0.9 µs per descriptor on an M4
352    /// Pro (216 µs for the 244 vnodes of a 442-descriptor process), which bounds
353    /// the whole walk at well under a millisecond, and it is far more rows than the
354    /// overlay can show without scrolling for a while.
355    pub const MAX_LISTED: usize = 256;
356
357    /// A listing of `entries` taken from a descriptor table of `total` entries.
358    ///
359    /// `entries` is truncated to [`OpenFileList::MAX_LISTED`] and everything the cap
360    /// left out is counted, so the panel can say how many descriptors it did not
361    /// list rather than presenting a partial list as a complete one (§4).
362    #[must_use]
363    pub fn listed(mut entries: Vec<OpenFileEntry>, total: usize) -> Self {
364        entries.truncate(Self::MAX_LISTED);
365        let not_listed = total.saturating_sub(entries.len());
366        Self {
367            entries,
368            not_listed: u32::try_from(not_listed).unwrap_or(u32::MAX),
369        }
370    }
371
372    /// The descriptors that were listed.
373    #[must_use]
374    pub fn entries(&self) -> &[OpenFileEntry] {
375        &self.entries
376    }
377
378    /// How many descriptors were listed.
379    #[must_use]
380    pub fn count(&self) -> usize {
381        self.entries.len()
382    }
383
384    /// How many descriptors the cap left out.
385    #[must_use]
386    pub const fn not_listed(&self) -> u32 {
387        self.not_listed
388    }
389
390    /// How many descriptors the process held when it was walked.
391    #[must_use]
392    pub fn total(&self) -> u64 {
393        u64::try_from(self.entries.len()).unwrap_or(u64::MAX) + u64::from(self.not_listed)
394    }
395
396    /// Whether every descriptor was listed.
397    #[must_use]
398    pub const fn is_complete(&self) -> bool {
399        self.not_listed == 0
400    }
401}
402
403/// One entry in a process's ancestry chain.
404#[derive(Clone, Debug, PartialEq)]
405#[cfg_attr(feature = "serde", derive(serde::Serialize))]
406pub struct AncestorEntry {
407    /// The ancestor's identity.
408    pub identity: ProcessIdentity,
409    /// The ancestor's short name, for the breadcrumb (§2.4).
410    pub name: Box<str>,
411}
412
413/// The expensive per-process fields, collected on demand for the selected
414/// process only (§8.6).
415///
416/// Environment variables are deliberately absent from this type, not merely
417/// hidden by default: §7.5 forbids showing their values, and §15.2 forbids
418/// logging them, so the safest design is never to read them at all.
419///
420/// The paths in [`ProcessDetail::open_file_list`] are user data of the same kind,
421/// and they *are* read because §7.2 asks for them on screen. What §15.2 and §19
422/// forbid is letting them leave the process, so they are redacted in the
423/// `Serialize` implementation itself rather than by whatever serializes them — see
424/// [`OpenFileEntry::path`].
425#[derive(Clone, Debug, PartialEq)]
426#[cfg_attr(feature = "serde", derive(serde::Serialize))]
427pub struct ProcessDetail {
428    /// Which process this describes.
429    ///
430    /// Checked against the current selection before rendering, so a late reply
431    /// for a process the user has moved off is discarded rather than shown
432    /// against the wrong row.
433    pub identity: ProcessIdentity,
434    /// Current working directory.
435    pub working_directory: MetricState<Box<str>>,
436    /// Filesystem root, which differs from `/` inside a container.
437    pub root: MetricState<Box<str>>,
438    /// Open file descriptor count.
439    pub open_files: MetricState<u32>,
440    /// Open socket count.
441    pub sockets: MetricState<u32>,
442    /// The descriptors themselves, bounded by [`OpenFileList::MAX_LISTED`].
443    ///
444    /// Separate from [`ProcessDetail::open_files`] because the two cost different
445    /// things: the count is one cheap read on both platforms, while the list is one
446    /// syscall per descriptor. A platform that can count but not list therefore
447    /// reports a count and an [`MetricState::Unsupported`] list rather than
448    /// withholding both.
449    pub open_file_list: MetricState<OpenFileList>,
450    /// Ancestry from the immediate parent up towards PID 1.
451    pub ancestry: MetricState<Vec<AncestorEntry>>,
452    /// Direct children.
453    pub children: MetricState<Vec<ProcessIdentity>>,
454    /// Total descendants, including indirect ones (§2.4).
455    pub descendants: MetricState<u32>,
456    /// Scheduling niceness.
457    pub nice: MetricState<i32>,
458    /// cgroup path. Linux only.
459    pub cgroup: MetricState<Box<str>>,
460    /// Container identity, where derivable from the cgroup path.
461    pub container: MetricState<Box<str>>,
462    /// When this detail was collected, so the UI can age it.
463    pub collected_at: SystemTime,
464}
465
466impl ProcessDetail {
467    /// An empty detail record for `identity`, with every field unmeasured.
468    #[must_use]
469    pub fn pending(identity: ProcessIdentity, collected_at: SystemTime) -> Self {
470        Self {
471            identity,
472            working_directory: MetricState::WarmingUp,
473            root: MetricState::WarmingUp,
474            open_files: MetricState::WarmingUp,
475            sockets: MetricState::WarmingUp,
476            open_file_list: MetricState::WarmingUp,
477            ancestry: MetricState::WarmingUp,
478            children: MetricState::WarmingUp,
479            descendants: MetricState::WarmingUp,
480            nice: MetricState::WarmingUp,
481            cgroup: MetricState::WarmingUp,
482            container: MetricState::WarmingUp,
483            collected_at,
484        }
485    }
486}
487
488/// The outcome of a detail lookup, which may fail because the process exited.
489#[derive(Clone, Debug, PartialEq)]
490pub enum ProcessDetailResult {
491    /// The lookup succeeded, at least partially.
492    Loaded(Box<ProcessDetail>),
493    /// The process no longer exists.
494    ///
495    /// Expected and not an error (§14.1).
496    Vanished(ProcessIdentity),
497    /// The PID now belongs to a different process.
498    Reused {
499        /// What was requested.
500        requested: ProcessIdentity,
501        /// What the PID refers to now.
502        found: ProcessIdentity,
503    },
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509
510    fn sample(command: &str, name: &str) -> ProcessSnapshot {
511        ProcessSnapshot {
512            identity: ProcessIdentity::new(31_842, 900_100),
513            parent_pid: Some(1),
514            name: name.into(),
515            command: command.into(),
516            exe: None,
517            user: MetricState::Unsupported,
518            state: ProcessState::Running,
519            cpu: MetricState::WarmingUp,
520            memory: ProcessMemory::WARMING_UP,
521            io: ProcessIo::UNSUPPORTED,
522            threads: MetricState::Unsupported,
523            age: MetricState::Unsupported,
524            started_at: MetricState::Unsupported,
525            is_kernel_thread: false,
526        }
527    }
528
529    #[test]
530    fn notable_states_are_exactly_zombie_and_uninterruptible_sleep() {
531        assert!(ProcessState::Zombie.is_notable());
532        assert!(ProcessState::UninterruptibleSleep.is_notable());
533        for state in [
534            ProcessState::Running,
535            ProcessState::Sleeping,
536            ProcessState::Stopped,
537            ProcessState::Traced,
538            ProcessState::Idle,
539            ProcessState::Dead,
540            ProcessState::Unknown,
541        ] {
542            assert!(!state.is_notable(), "{state:?}");
543        }
544    }
545
546    #[test]
547    fn state_codes_are_unique_so_the_column_is_unambiguous() {
548        let states = [
549            ProcessState::Running,
550            ProcessState::Sleeping,
551            ProcessState::UninterruptibleSleep,
552            ProcessState::Zombie,
553            ProcessState::Stopped,
554            ProcessState::Traced,
555            ProcessState::Idle,
556            ProcessState::Dead,
557            ProcessState::Unknown,
558        ];
559        let mut codes: Vec<char> = states.iter().map(|s| s.code()).collect();
560        codes.sort_unstable();
561        codes.dedup();
562        assert_eq!(codes.len(), states.len());
563    }
564
565    #[test]
566    fn already_exited_processes_are_not_signalable() {
567        assert!(!ProcessState::Zombie.is_signalable());
568        assert!(!ProcessState::Dead.is_signalable());
569        assert!(ProcessState::Running.is_signalable());
570        assert!(ProcessState::UninterruptibleSleep.is_signalable());
571    }
572
573    #[test]
574    fn an_empty_command_falls_back_to_the_process_name() {
575        let kernel_thread = sample("", "kworker/2:1");
576        assert_eq!(kernel_thread.command_or_name(), "kworker/2:1");
577        let normal = sample("cargo build --release", "cargo");
578        assert_eq!(normal.command_or_name(), "cargo build --release");
579    }
580
581    #[test]
582    fn redaction_strips_arguments_which_may_contain_secrets() {
583        let process = sample("psql postgres://user:hunter2@db/prod", "psql");
584        assert_eq!(process.redacted_command(), "psql");
585        assert!(!process.redacted_command().contains("hunter2"));
586    }
587
588    #[test]
589    fn redaction_of_a_bare_program_keeps_the_program() {
590        assert_eq!(sample("rustc", "rustc").redacted_command(), "rustc");
591        assert_eq!(sample("", "kworker/2:1").redacted_command(), "kworker/2:1");
592    }
593
594    fn descriptor(
595        descriptor: i32,
596        kind: OpenFileKind,
597        path: MetricState<Box<str>>,
598    ) -> OpenFileEntry {
599        OpenFileEntry {
600            descriptor,
601            kind,
602            path,
603        }
604    }
605
606    #[test]
607    fn only_a_file_descriptor_can_have_a_path_to_refuse() {
608        // The distinction the collectors depend on: a socket's missing path is a fact
609        // about sockets, so failing to resolve one is not a read failure to report.
610        assert!(OpenFileKind::File.has_path());
611        for kind in [
612            OpenFileKind::Socket,
613            OpenFileKind::Pipe,
614            OpenFileKind::EventQueue,
615            OpenFileKind::SharedMemory,
616            OpenFileKind::Semaphore,
617            OpenFileKind::Unknown,
618        ] {
619            assert!(!kind.has_path(), "{kind:?}");
620        }
621    }
622
623    #[test]
624    fn every_descriptor_kind_has_a_distinct_ascii_label() {
625        // The label is the only thing on screen that tells a socket from a pipe when
626        // neither has a path, so two kinds sharing one label would erase §4's answer.
627        let kinds = [
628            OpenFileKind::File,
629            OpenFileKind::Socket,
630            OpenFileKind::Pipe,
631            OpenFileKind::EventQueue,
632            OpenFileKind::SharedMemory,
633            OpenFileKind::Semaphore,
634            OpenFileKind::Unknown,
635        ];
636        let mut labels: Vec<&str> = kinds.iter().map(|kind| kind.label()).collect();
637        for label in &labels {
638            assert!(label.is_ascii(), "{label} is not strict ASCII");
639        }
640        labels.sort_unstable();
641        labels.dedup();
642        assert_eq!(labels.len(), kinds.len());
643    }
644
645    #[test]
646    fn the_listing_cap_is_enforced_by_the_constructor_rather_than_by_its_callers() {
647        // If a collector could hand over more entries than the cap, the cap would be
648        // a convention rather than a bound, and §16.1 asks for a bound.
649        let entries: Vec<OpenFileEntry> = (0..OpenFileList::MAX_LISTED + 50)
650            .map(|index| {
651                descriptor(
652                    i32::try_from(index).unwrap_or(i32::MAX),
653                    OpenFileKind::File,
654                    MetricState::Available("/tmp/x".into()),
655                )
656            })
657            .collect();
658        let list = OpenFileList::listed(entries, OpenFileList::MAX_LISTED + 50);
659        assert_eq!(list.count(), OpenFileList::MAX_LISTED);
660        assert_eq!(list.not_listed(), 50);
661        assert!(!list.is_complete());
662    }
663
664    #[test]
665    fn a_complete_listing_says_nothing_was_left_out() {
666        let list = OpenFileList::listed(
667            vec![descriptor(
668                3,
669                OpenFileKind::Socket,
670                MetricState::Unsupported,
671            )],
672            1,
673        );
674        assert!(list.is_complete());
675        assert_eq!(list.not_listed(), 0);
676        assert_eq!(list.total(), 1);
677    }
678
679    #[test]
680    fn a_total_below_the_listed_count_cannot_produce_a_negative_remainder() {
681        // A descriptor table that shrank between the two reads must not underflow into
682        // a claim that four billion descriptors were omitted.
683        let list = OpenFileList::listed(
684            vec![
685                descriptor(3, OpenFileKind::File, MetricState::PermissionDenied),
686                descriptor(4, OpenFileKind::Pipe, MetricState::Unsupported),
687            ],
688            1,
689        );
690        assert_eq!(list.not_listed(), 0);
691        assert_eq!(list.total(), 2);
692    }
693
694    #[test]
695    fn an_unreadable_descriptor_path_is_a_state_and_never_an_empty_string() {
696        let list = OpenFileList::listed(
697            vec![
698                descriptor(3, OpenFileKind::File, MetricState::PermissionDenied),
699                descriptor(7, OpenFileKind::Socket, MetricState::Unsupported),
700            ],
701            2,
702        );
703        for entry in list.entries() {
704            assert!(entry.path.fresh().is_none_or(|path| !path.is_empty()));
705            assert!(entry.path.placeholder().is_some() || entry.path.fresh().is_some());
706        }
707    }
708
709    #[test]
710    fn a_pending_detail_reports_nothing_as_measured() {
711        let identity = ProcessIdentity::new(1, 2);
712        let detail = ProcessDetail::pending(identity, SystemTime::UNIX_EPOCH);
713        assert_eq!(detail.identity, identity);
714        assert!(detail.working_directory.is_warming_up());
715        assert!(detail.open_files.fresh().is_none());
716        assert!(
717            detail.open_file_list.is_warming_up(),
718            "an unread descriptor list is not an empty one"
719        );
720    }
721}