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}