gander_core/row.rs
1//! Field identifiers and their presentation metadata.
2
3use crate::Pid;
4
5/// A displayable per-process column.
6///
7/// Variants mirror htop's `RowField.h` / `<platform>/ProcessField.h` semantics.
8/// The numeric IDs deliberately do *not* match htop: only the `htoprc` importer
9/// needs name-level compatibility.
10///
11/// Render with `match`, never `dyn` dispatch — thousands of rows times dozens of
12/// columns per frame makes dynamic dispatch measurable.
13#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug, PartialOrd, Ord)]
14#[non_exhaustive]
15pub enum Field {
16 Pid,
17 Comm,
18 State,
19 Ppid,
20 Pgrp,
21 Session,
22 Tty,
23 Tpgid,
24 MinFlt,
25 MajFlt,
26 Priority,
27 Nice,
28 StartTime,
29 Processor,
30 VirtMem,
31 ResMem,
32 User,
33 PercentCpu,
34 PercentMem,
35 Time,
36 Nlwp,
37 Tgid,
38 PercentNormCpu,
39 Elapsed,
40 SchedPolicy,
41 ProcComm,
42 ProcExe,
43 Cwd,
44
45 // Linux-only fields (see docs/DESIGN.md, milestone M3).
46 #[cfg(target_os = "linux")]
47 Pss,
48 #[cfg(target_os = "linux")]
49 Swap,
50 #[cfg(target_os = "linux")]
51 Private,
52 #[cfg(target_os = "linux")]
53 Epss,
54 #[cfg(target_os = "linux")]
55 Cgroup,
56 #[cfg(target_os = "linux")]
57 Container,
58 #[cfg(target_os = "linux")]
59 GpuPercent,
60 #[cfg(target_os = "linux")]
61 GpuTime,
62 #[cfg(target_os = "linux")]
63 CpuDelayPercent,
64 #[cfg(target_os = "linux")]
65 IoDelayPercent,
66 #[cfg(target_os = "linux")]
67 SwapDelayPercent,
68}
69
70impl Field {
71 /// Whether reading this column requires `/proc/<pid>/smaps_rollup`.
72 ///
73 /// The sampler consults this before spending tens of milliseconds per tick on
74 /// a kernel VMA walk: a column that is not on screen must not cost anything.
75 /// Kept next to the `Field` definition so a future PSS-derived column cannot
76 /// be added without answering the question.
77 pub const fn needs_smaps_rollup(self) -> bool {
78 #[cfg(target_os = "linux")]
79 {
80 matches!(self, Field::Pss | Field::Epss | Field::Private | Field::Swap)
81 }
82 #[cfg(not(target_os = "linux"))]
83 {
84 let _ = self;
85 false
86 }
87 }
88
89 /// Whether reading this column requires a `TASKSTATS` netlink query.
90 ///
91 /// Same contract as [`Self::needs_smaps_rollup`], with a higher price: every
92 /// query is a syscall pair and a kernel task lookup, per process, per tick.
93 pub const fn needs_delayacct(self) -> bool {
94 #[cfg(target_os = "linux")]
95 {
96 matches!(
97 self,
98 Field::CpuDelayPercent | Field::IoDelayPercent | Field::SwapDelayPercent
99 )
100 }
101 #[cfg(not(target_os = "linux"))]
102 {
103 let _ = self;
104 false
105 }
106 }
107
108 /// Whether reading this column requires scanning `/proc/<pid>/fdinfo`.
109 ///
110 /// Same contract as [`Self::needs_smaps_rollup`]: a column that is not on
111 /// screen must not cost a directory walk per process per tick.
112 pub const fn needs_gpu_fdinfo(self) -> bool {
113 #[cfg(target_os = "linux")]
114 {
115 matches!(self, Field::GpuPercent | Field::GpuTime)
116 }
117 #[cfg(not(target_os = "linux"))]
118 {
119 let _ = self;
120 false
121 }
122 }
123}
124
125/// Horizontal alignment of a column.
126#[derive(Copy, Clone, PartialEq, Eq, Debug)]
127pub enum Align {
128 Left,
129 Right,
130}
131
132/// Static description of a field, used by the setup screen and by the renderer.
133pub struct FieldMeta {
134 pub field: Field,
135 /// Stable identifier used in config files (`"M_PSS"`).
136 pub name: &'static str,
137 /// Header cell text (`" PSS "`).
138 pub title: &'static str,
139 /// Human description shown in the setup screen.
140 pub description: &'static str,
141 /// Whether this column sorts descending by default.
142 pub default_sort_desc: bool,
143 pub align: Align,
144}
145
146impl Field {
147 pub fn meta(self) -> Option<&'static FieldMeta> {
148 FIELD_META.iter().find(|m| m.field == self)
149 }
150
151 /// Stable config name, e.g. `PercentCpu` -> `"PERCENT_CPU"`.
152 pub fn name(self) -> &'static str {
153 self.meta().map_or("UNKNOWN", |m| m.name)
154 }
155
156 /// Header text, e.g. `PercentCpu` -> `" CPU% "`.
157 pub fn title(self) -> &'static str {
158 self.meta().map_or(" ? ", |m| m.title)
159 }
160
161 pub fn default_sort_desc(self) -> bool {
162 self.meta().is_some_and(|m| m.default_sort_desc)
163 }
164
165 /// All fields available on this platform, in setup-screen order.
166 pub fn all() -> impl Iterator<Item = Field> {
167 FIELD_META.iter().map(|m| m.field)
168 }
169
170 pub fn from_name(name: &str) -> Option<Field> {
171 FIELD_META.iter().find(|m| m.name == name).map(|m| m.field)
172 }
173}
174
175const fn m(
176 field: Field,
177 name: &'static str,
178 title: &'static str,
179 description: &'static str,
180 default_sort_desc: bool,
181 align: Align,
182) -> FieldMeta {
183 FieldMeta {
184 field,
185 name,
186 title,
187 description,
188 default_sort_desc,
189 align,
190 }
191}
192
193const FIELD_META: &[FieldMeta] = &[
194 m(
195 Field::Pid,
196 "PID",
197 " PID ",
198 "Process ID",
199 false,
200 Align::Right,
201 ),
202 m(
203 Field::Comm,
204 "COMM",
205 " COMMAND ",
206 "Command name from /proc/<pid>/comm",
207 false,
208 Align::Left,
209 ),
210 m(
211 Field::State,
212 "STATE",
213 " S ",
214 "Process state",
215 false,
216 Align::Left,
217 ),
218 m(
219 Field::Ppid,
220 "PPID",
221 " PPID ",
222 "Parent process ID",
223 false,
224 Align::Right,
225 ),
226 m(
227 Field::Pgrp,
228 "PGRP",
229 " PGRP ",
230 "Process group ID",
231 false,
232 Align::Right,
233 ),
234 m(
235 Field::User,
236 "USER",
237 " USER ",
238 "Effective user",
239 false,
240 Align::Left,
241 ),
242 m(
243 Field::Priority,
244 "PRIORITY",
245 " PRI ",
246 "Kernel scheduling priority",
247 false,
248 Align::Right,
249 ),
250 m(
251 Field::Nice,
252 "NICE",
253 " NI ",
254 "Nice value",
255 false,
256 Align::Right,
257 ),
258 m(
259 Field::PercentCpu,
260 "PERCENT_CPU",
261 " CPU% ",
262 "CPU usage in percent",
263 true,
264 Align::Right,
265 ),
266 m(
267 Field::PercentMem,
268 "PERCENT_MEM",
269 " MEM% ",
270 "Memory usage in percent",
271 true,
272 Align::Right,
273 ),
274 m(
275 Field::PercentNormCpu,
276 "PERCENT_NORM_CPU",
277 " NCPU% ",
278 "CPU usage normalized by core count",
279 true,
280 Align::Right,
281 ),
282 m(
283 Field::Time,
284 "TIME",
285 " TIME+ ",
286 "Cumulative CPU time",
287 true,
288 Align::Right,
289 ),
290 m(
291 Field::Elapsed,
292 "ELAPSED",
293 " ELAPSED ",
294 "Time since the process started",
295 false,
296 Align::Right,
297 ),
298 m(
299 Field::ResMem,
300 "M_RESIDENT",
301 " RES ",
302 "Resident set size",
303 true,
304 Align::Right,
305 ),
306 m(
307 Field::VirtMem,
308 "M_VIRT",
309 " VIRT ",
310 "Virtual memory size",
311 true,
312 Align::Right,
313 ),
314 m(
315 Field::Nlwp,
316 "NLWP",
317 " NLWP ",
318 "Number of threads",
319 false,
320 Align::Right,
321 ),
322 m(
323 Field::Processor,
324 "PROCESSOR",
325 " CPU ",
326 "Last used CPU",
327 false,
328 Align::Right,
329 ),
330 m(
331 Field::MinFlt,
332 "MINFLT",
333 " MINFLT ",
334 "Minor page faults",
335 false,
336 Align::Right,
337 ),
338 m(
339 Field::MajFlt,
340 "MAJFLT",
341 " MAJFLT ",
342 "Major page faults",
343 false,
344 Align::Right,
345 ),
346 // Session and the controlling terminal. Both are in `/proc/<pid>/stat` (fields
347 // 4 and 5) and in Darwin's `kinfo_proc`, so they are real on every platform;
348 // they were declared as `Field` variants with no metadata, which made them
349 // unreachable: no name, no header, no setup entry, and an `htoprc` that named
350 // them imported to the string `"UNKNOWN"` and was silently dropped.
351 m(
352 Field::Session,
353 "SESSION",
354 " SID ",
355 "Session ID: the login session this process belongs to",
356 false,
357 Align::Right,
358 ),
359 m(
360 Field::Tty,
361 "TTY",
362 " TTY ",
363 "Controlling terminal, or `?` when there is none",
364 false,
365 Align::Left,
366 ),
367 // The process group a *terminal* is currently sending its signals to. Equal to
368 // PGRP unless one of the jobs is in the foreground.
369 m(
370 Field::Tpgid,
371 "TPGID",
372 " TPGID ",
373 "Process group of the foreground job on this terminal",
374 false,
375 Align::Right,
376 ),
377 // Wall-clock start, not the `starttime` ticks kept for PID-reuse detection:
378 // ticks since boot mean nothing without the boot time next to them.
379 m(
380 Field::StartTime,
381 "STARTTIME",
382 " START ",
383 "Wall-clock time this process started",
384 false,
385 Align::Left,
386 ),
387 m(
388 Field::Tgid,
389 "TGID",
390 " TGID ",
391 "Thread group ID: the pid of the process a thread row belongs to",
392 false,
393 Align::Right,
394 ),
395 m(
396 Field::SchedPolicy,
397 "SCHED_POLICY",
398 " POL ",
399 "Scheduling policy: other, fifo, rr, batch, idle, deadline",
400 false,
401 Align::Left,
402 ),
403 // The two PROC_* fields exist because `COMM` is the *command line* here (what
404 // htop's Command column shows), so the kernel's own short name needs its own
405 // column. htop draws the same distinction: `COMM` vs `PROC_COMM`.
406 m(
407 Field::ProcComm,
408 "PROC_COMM",
409 " COMMAND ",
410 "Kernel's short command name, always the executable's base name",
411 false,
412 Align::Left,
413 ),
414 m(
415 Field::ProcExe,
416 "PROC_EXE",
417 " EXE ",
418 "Path to the executable, as the kernel resolved it",
419 false,
420 Align::Left,
421 ),
422 m(
423 Field::Cwd,
424 "CWD",
425 " CWD ",
426 "Working directory of the process",
427 false,
428 Align::Left,
429 ),
430 // Linux-only: cgroup membership, and the container derived from it.
431 #[cfg(target_os = "linux")]
432 m(
433 Field::Cgroup,
434 "CGROUP",
435 " CGROUP ",
436 "Full cgroup path",
437 false,
438 Align::Left,
439 ),
440 #[cfg(target_os = "linux")]
441 m(
442 Field::Container,
443 "CONTAINER",
444 " CONTAINER ",
445 "Container id, prefixed with its runtime",
446 false,
447 Align::Left,
448 ),
449 // The PSS family. All four come from `/proc/<pid>/smaps_rollup`, which is why
450 // they share `Field::needs_smaps_rollup` — the sampler reads it only when one
451 // of these is on screen.
452 #[cfg(target_os = "linux")]
453 m(
454 Field::Pss,
455 "M_PSS",
456 " PSS ",
457 "Proportional set size: shared pages divided between sharers",
458 true,
459 Align::Right,
460 ),
461 #[cfg(target_os = "linux")]
462 m(
463 Field::Epss,
464 "M_EPSS",
465 " EPSS ",
466 "Effective PSS: PSS plus the swapped-out equivalent",
467 true,
468 Align::Right,
469 ),
470 #[cfg(target_os = "linux")]
471 m(
472 Field::Private,
473 "M_PRIVATE",
474 " PRIV ",
475 "Private memory: what would be freed if this process exited",
476 true,
477 Align::Right,
478 ),
479 #[cfg(target_os = "linux")]
480 m(
481 Field::Swap,
482 "M_SWAP",
483 " SWP ",
484 "Memory this process has pushed to swap",
485 true,
486 Align::Right,
487 ),
488 // GPU, from `drm-usage-stats` keys in `fdinfo`. No vendor SDK is involved:
489 // the kernel exports these for every driver that opts in, which today means
490 // amdgpu, i915/xe, nouveau and (partially) nvidia.
491 #[cfg(target_os = "linux")]
492 m(
493 Field::GpuPercent,
494 "GPU%",
495 " GPU% ",
496 "Share of GPU time this process used, from drm-usage-stats",
497 true,
498 Align::Right,
499 ),
500 #[cfg(target_os = "linux")]
501 m(
502 Field::GpuTime,
503 "GPU_TIME",
504 " GPUT ",
505 "Cumulative GPU time, across every engine class",
506 true,
507 Align::Right,
508 ),
509 // Delay accounting, from `TASKSTATS` over generic netlink. The figures are
510 // shares of the task's lifetime spent *waiting*: runnable but not on a CPU;
511 // blocked on synchronous I/O; waiting for a page to be swapped back in.
512 //
513 // htop names these `PERCENT_CPU_DELAY`, `PERCENT_IO_DELAY` and
514 // `PERCENT_SWAP_DELAY`; the ids match htop's `ProcessField.h` so an htoprc
515 // imports.
516 #[cfg(target_os = "linux")]
517 m(
518 Field::CpuDelayPercent,
519 "CPUD%",
520 "CPUD% ",
521 "Share of this task's life spent runnable but waiting for a CPU",
522 true,
523 Align::Right,
524 ),
525 #[cfg(target_os = "linux")]
526 m(
527 Field::IoDelayPercent,
528 "IOD%",
529 " IOD% ",
530 "Share of this task's life spent waiting for synchronous block I/O",
531 true,
532 Align::Right,
533 ),
534 #[cfg(target_os = "linux")]
535 m(
536 Field::SwapDelayPercent,
537 "SWPD%",
538 "SWPD% ",
539 "Share of this task's life spent waiting for a page to be swapped in",
540 true,
541 Align::Right,
542 ),
543];
544
545/// Names of the fields shown in the default M0 layout.
546pub const DEFAULT_COLUMNS: &[Field] = &[
547 Field::Pid,
548 Field::User,
549 Field::Priority,
550 Field::Nice,
551 Field::PercentCpu,
552 Field::PercentMem,
553 Field::Time,
554 Field::Comm,
555];
556
557/// A process name, bounded to avoid any heap allocation during a scan.
558pub type CommBuf = arrayvec::ArrayString<64>;
559
560/// Raw, monotonically increasing CPU tick counters for one process.
561///
562/// CPU percent can only be derived from two samples; see `docs/DESIGN.md` §3.5.
563#[derive(Copy, Clone, Default, Debug)]
564pub struct CpuTicks {
565 /// `utime + stime` of the process, in clock ticks.
566 pub proc: u64,
567 /// System-wide total ticks across all CPUs at the same instant.
568 pub total: u64,
569}
570
571bitflags_like::flags! {
572 /// Properties of a row that affect rendering and filtering.
573 pub struct RowFlags: u16 {
574 const KERNEL_THREAD = 1 << 0;
575 const USERLAND_THREAD = 1 << 1;
576 const STALE_EXE = 1 << 2;
577 const STALE_LIB = 1 << 3;
578 const NEW = 1 << 4;
579 }
580}
581
582/// Internal shim: a tiny bitflags implementation, to avoid a dependency.
583mod bitflags_like {
584 macro_rules! flags {
585 (
586 $(#[$meta:meta])*
587 pub struct $name:ident: $ty:ty {
588 $(
589 const $flag:ident = $value:expr;
590 )*
591 }
592 ) => {
593 $(#[$meta])*
594 #[derive(Copy, Clone, Default, PartialEq, Eq, Debug)]
595 pub struct $name(pub $ty);
596
597 impl $name {
598 $(pub const $flag: $name = $name($value);)*
599
600 pub const fn empty() -> Self { $name(0) }
601
602 pub const fn contains(self, other: Self) -> bool {
603 (self.0 & other.0) == other.0
604 }
605
606 pub const fn intersects(self, other: Self) -> bool {
607 (self.0 & other.0) != 0
608 }
609
610 pub const fn insert(self, other: Self) -> Self {
611 $name(self.0 | other.0)
612 }
613
614 pub const fn remove(self, other: Self) -> Self {
615 $name(self.0 & !other.0)
616 }
617
618 pub const fn set(self, other: Self, on: bool) -> Self {
619 if on { self.insert(other) } else { self.remove(other) }
620 }
621 }
622 };
623 }
624
625 pub(crate) use flags;
626}
627
628/// One process, as sampled during a single tick.
629///
630/// **Invariant: `Row` stores raw scalars and interned strings only.** Any
631/// `String` produced by `format!` during a scan is a bug — see
632/// `docs/DESIGN.md` §9.4. Human-readable text is produced by the renderer
633/// through `crate::format`.
634#[derive(Clone, Debug)]
635pub struct Row {
636 pub pid: Pid,
637 pub ppid: Pid,
638 pub pgrp: Pid,
639 pub session: Pid,
640 pub tty_nr: i64,
641 /// The process group the controlling terminal is currently signalling.
642 ///
643 /// Equal to `pgrp` unless a job has the terminal in the foreground, which is
644 /// the one distinction this column exists to show.
645 ///
646 /// `-1` is what the kernel reports when the terminal has no foreground job,
647 /// and it is also what a thread row carries. The renderer shows `-` for it
648 /// rather than the number: `-1` is a wire value, not a process group.
649 pub tpgid: i64,
650 /// Field 22 of `/proc/<pid>/stat`: cache key and PID-reuse detector.
651 pub starttime: u64,
652 /// Thread group id: the pid of the process this row belongs to.
653 ///
654 /// For a process this equals `pid`. For a thread it is the leader's pid,
655 /// which is what lets the table nest threads under their process and keep
656 /// the selection stable while thread enumeration is toggled.
657 pub tgid: Pid,
658 /// True when this row is a thread rather than a process.
659 ///
660 /// Kept separate from `RowFlags::USERLAND_THREAD` because that flag answers
661 /// "is this a userland thread" for display purposes, while this answers a
662 /// structural question the table has to get right.
663 pub is_thread: bool,
664 /// Identity of a thread row, unique across the whole table.
665 ///
666 /// For a process row this is `None` and `pid` is the identity. For a thread it
667 /// is `Some`, because on macOS a thread has no pid of its own: `pth` handles
668 /// are 64-bit (95% exceed `u32`) and are *not* globally unique (every process
669 /// reports the same handle for its main thread). Linux threads do have a tid
670 /// that fits in `pid`, but it can coincide with an unrelated process's pid, so
671 /// thread rows use this on both platforms.
672 pub thread_id: Option<u64>,
673
674 /// `ArrayString<64>`: no heap allocation per scanned process.
675 pub comm: CommBuf,
676 pub state: u8,
677
678 pub uid: u32,
679 pub user: Option<u32>, // index into the user-name table
680
681 pub priority: i8,
682 pub nice: i8,
683 pub processor: i32,
684 pub nlwp: i32,
685 pub sched_policy: u8,
686
687 /// Raw tick counters, current and previous sample.
688 pub ticks: CpuTicks,
689 pub prev_ticks: CpuTicks,
690
691 pub min_flt: u64,
692 pub maj_flt: u64,
693
694 /// Virtual and resident size in bytes.
695 pub virt: u64,
696 pub res: u64,
697
698 /// Derived values, computed at sample time (not at render time).
699 pub percent_cpu: f32,
700 pub percent_mem: f32,
701 /// `utime + stime` in ticks, kept for the TIME column.
702 pub time_ticks: u64,
703 /// Seconds since process start.
704 pub elapsed: u64,
705
706 /// Lazily loaded, cached until the process exits.
707 pub cmdline: Option<Box<str>>,
708 pub exe: Option<Box<str>>,
709 /// The process's working directory, read from `/proc/<pid>/cwd` or, on macOS,
710 /// from `proc_pidinfo(PROC_PIDVNODEPATHINFO)`.
711 ///
712 /// Cached with the identity because a `readlink` per row per tick is
713 /// thousands of syscalls for a value that changes rarely — even though, unlike
714 /// `exe`, it *can* change. Accepting a stale value until the process exits is
715 /// the same trade htop makes.
716 pub cwd: Option<Box<str>>,
717 /// Proportional set size, from `/proc/<pid>/smaps_rollup`, in bytes.
718 ///
719 /// `None` means "not read", not "zero": the file is expensive enough that
720 /// it is only opened when a column needing it is visible, and another user's
721 /// process may refuse to be read at all. The renderer shows `-` for `None`
722 /// and a real figure for `Some(0)`.
723 ///
724 /// RSS counts a shared page once per process, so two processes sharing 100 MB
725 /// of libraries report 100 MB each and the machine appears to use 200 MB.
726 /// PSS splits that page between them, which is the only figure that adds up
727 /// across the table.
728 #[cfg(target_os = "linux")]
729 pub pss: Option<u64>,
730 /// `Pss` + `SwapPss`: what the process costs including what it pushed to swap.
731 #[cfg(target_os = "linux")]
732 pub epss: Option<u64>,
733 /// Memory exclusively this process's (`Private_Clean + Private_Dirty`).
734 ///
735 /// The complement of the shared part: PSS minus this is what would be freed
736 /// if the process exited and nothing else changed.
737 #[cfg(target_os = "linux")]
738 pub private: Option<u64>,
739 /// Bytes of this process's memory currently in swap (`Swap` from rollup).
740 #[cfg(target_os = "linux")]
741 pub swap_bytes: Option<u64>,
742 /// GPU utilisation in percent, from `drm-usage-stats` keys in `fdinfo`.
743 ///
744 /// `None` when the process holds no DRM descriptor, or holds one whose driver
745 /// reports no `drm-busy`, or the `fdinfo` files are unreadable. Those are
746 /// three different reasons for the same thing — "no percentage is available"
747 /// — and none of them is zero.
748 #[cfg(target_os = "linux")]
749 pub gpu_percent: Option<f32>,
750 /// Cumulative GPU nanoseconds, across every engine class the driver reports.
751 #[cfg(target_os = "linux")]
752 pub gpu_ns: Option<u64>,
753 /// Share of this task's life spent runnable but waiting for a CPU.
754 ///
755 /// From `TASKSTATS`; see `linux::delayacct`. `None` means unread — no
756 /// `CAP_NET_ADMIN`, no `CONFIG_TASKSTATS`, or no delay column visible — and
757 /// renders `-`, never `0.0`.
758 pub cpu_delay_percent: Option<f32>,
759 /// Share of this task's life spent waiting for synchronous block I/O.
760 pub io_delay_percent: Option<f32>,
761 /// Share of this task's life spent waiting for a page to be swapped in.
762 pub swap_delay_percent: Option<f32>,
763 /// The process's cgroup path, e.g. `/system.slice/docker-1f2e….scope`.
764 ///
765 /// The *path*, not the raw `/proc/<pid>/cgroup` line: the raw line is
766 /// `12:cpuset:/docker/abc` and carries the hierarchy id and controller list,
767 /// which no column wants. The v2 entry (empty controller list) is preferred
768 /// when present, because that is the hierarchy the whole machine shares.
769 pub cgroup: Option<Box<str>>,
770 /// The container this process belongs to, if any.
771 ///
772 /// Derived from `cgroup` once per process and cached with it. Linux-only
773 /// because the detection is cgroup-based; `None` everywhere else.
774 #[cfg(target_os = "linux")]
775 pub container: Option<Box<str>>,
776 /// The runtime that owns the container (`docker`, `kubepods`, …), kept apart
777 /// from `container` so the `CONTAINER` column can show `docker:1f2e…` without
778 /// re-parsing the path at render time.
779 #[cfg(target_os = "linux")]
780 pub container_runtime: Option<Box<str>>,
781
782 /// Tree bookkeeping, filled by `tree::build`.
783 pub depth: u16,
784 pub has_children: bool,
785 pub is_last_child: bool,
786
787 pub flags: RowFlags,
788}
789
790impl Row {
791 /// Construct a minimal row, used by tests and by the tree helpers.
792 pub fn default_for_test(pid: Pid) -> Self {
793 Row {
794 pid,
795 ppid: 0,
796 pgrp: 0,
797 session: 0,
798 tty_nr: 0,
799 tpgid: -1,
800 starttime: 0,
801 tgid: pid,
802 is_thread: false,
803 thread_id: None,
804 comm: CommBuf::new(),
805 state: b'R',
806 uid: 0,
807 user: None,
808 priority: 20,
809 nice: 0,
810 processor: 0,
811 nlwp: 1,
812 sched_policy: 0,
813 ticks: CpuTicks::default(),
814 prev_ticks: CpuTicks::default(),
815 min_flt: 0,
816 maj_flt: 0,
817 virt: 0,
818 res: 0,
819 percent_cpu: 0.0,
820 percent_mem: 0.0,
821 time_ticks: 0,
822 elapsed: 0,
823 cmdline: None,
824 exe: None,
825 cwd: None,
826 #[cfg(target_os = "linux")]
827 pss: None,
828 #[cfg(target_os = "linux")]
829 epss: None,
830 #[cfg(target_os = "linux")]
831 private: None,
832 #[cfg(target_os = "linux")]
833 swap_bytes: None,
834 #[cfg(target_os = "linux")]
835 gpu_percent: None,
836 #[cfg(target_os = "linux")]
837 gpu_ns: None,
838 cpu_delay_percent: None,
839 io_delay_percent: None,
840 swap_delay_percent: None,
841 cgroup: None,
842 #[cfg(target_os = "linux")]
843 container: None,
844 #[cfg(target_os = "linux")]
845 container_runtime: None,
846 depth: 0,
847 has_children: false,
848 is_last_child: false,
849 flags: RowFlags::empty(),
850 }
851 }
852
853 /// Major page faults.
854 ///
855 /// Exposed as a method so sort keys and renderers read symmetrically.
856 pub const fn maj_flt_count(&self) -> u64 {
857 self.maj_flt
858 }
859
860 /// Total CPU ticks consumed by the process.
861 pub const fn time_ticks_total(&self) -> u64 {
862 self.time_ticks
863 }
864
865 pub fn is_kernel_thread(&self) -> bool {
866 self.flags.contains(RowFlags::KERNEL_THREAD)
867 }
868
869 /// The value that identifies this row across samples.
870 ///
871 /// `pid` for a process, a thread-specific id for a thread. Used wherever a
872 /// row must be told apart from another row with the same `pid`, which on
873 /// macOS every thread of a process shares.
874 pub fn identity(&self) -> u64 {
875 match self.thread_id {
876 Some(id) => id,
877 None => self.pid as u64,
878 }
879 }
880
881 /// Text for the command column: `cmdline` if known, else `comm`, with
882 /// kernel threads bracketed per htop's convention.
883 pub fn display_command(&self) -> &str {
884 match &self.cmdline {
885 Some(c) if !c.is_empty() => c,
886 _ => self.comm.as_str(),
887 }
888 }
889
890 /// The executable's base name, and where it sits in [`Self::display_command`].
891 ///
892 /// Returns `(start, end)` byte offsets into `display_command()` so the renderer
893 /// can emphasise those cells without re-deriving the text or allocating.
894 ///
895 /// # Why this is not just "the last path component"
896 ///
897 /// The command line is `argv` joined by spaces, so a naive split on `/` finds
898 /// a path in an *argument* too (`grep /usr/lib/foo`). The base name is taken
899 /// from `exe` when the sampler has it, and located in the command text as a
900 /// prefix match on that path; when `exe` is unavailable (another user's
901 /// process) it falls back to the first word of the command, which is what
902 /// argv[0] is.
903 ///
904 /// Returns `None` when there is nothing worth highlighting — a kernel thread
905 /// in brackets, or a command whose base name is the whole string.
906 pub fn base_name_span(&self) -> Option<(usize, usize)> {
907 self.base_name_span_in(self.display_command())
908 }
909
910 /// [`Self::base_name_span`] against `text` instead of the command column text.
911 ///
912 /// The renderer prepends the tree branch prefix to the command column, so the
913 /// span it needs is offset by that prefix's length. Searching the *finished*
914 /// text is simpler and more robust than adding an offset to the span: the
915 /// prefix can only appear before the command, and the exe path search below
916 /// does not match it.
917 pub fn base_name_span_in(&self, text: &str) -> Option<(usize, usize)> {
918 if text.is_empty() {
919 return None;
920 }
921
922 // Prefer the real exe path: a prefix of the command line, when we have it.
923 if let Some(exe) = self.exe.as_deref() {
924 if !exe.is_empty() {
925 if let Some(pos) = text.find(exe) {
926 let start = pos + exe.rfind('/').map_or(0, |i| i + 1);
927 let end = pos + exe.len();
928 if end > start {
929 return Some((start, end));
930 }
931 }
932 }
933 }
934
935 // Fall back to argv[0]: everything up to the first space.
936 let end = text.find(' ').unwrap_or(text.len());
937 let word = &text[..end];
938 // A bracketed name is a kernel thread, and htop does not highlight those.
939 if word.starts_with('[') {
940 return None;
941 }
942 let start = word.rfind('/').map_or(0, |i| i + 1);
943 // Nothing to gain from highlighting the entire string.
944 if start == 0 && end == text.len() {
945 return None;
946 }
947 if end > start {
948 Some((start, end))
949 } else {
950 None
951 }
952 }
953}
954
955#[cfg(test)]
956mod base_name_tests {
957 use super::*;
958
959 fn row_with(cmdline: &str, exe: Option<&str>) -> Row {
960 let mut r = Row::default_for_test(1);
961 r.cmdline = Some(cmdline.into());
962 r.exe = exe.map(Into::into);
963 r
964 }
965
966 #[test]
967 fn finds_the_basename_inside_a_full_path() {
968 let r = row_with(
969 "/Applications/iTerm.app/Contents/MacOS/iTerm2 --server",
970 Some("/Applications/iTerm.app/Contents/MacOS/iTerm2"),
971 );
972 let text = r.display_command();
973 let (s, e) = r.base_name_span().expect("span");
974 assert_eq!(&text[s..e], "iTerm2");
975 }
976
977 /// The exe path is authoritative, so an argument that looks like a path must
978 /// not win. `grep /usr/lib/foo` should highlight `grep`.
979 #[test]
980 fn an_argument_path_does_not_win_over_the_exe() {
981 let r = row_with("grep /usr/lib/foo", Some("/usr/bin/grep"));
982 let text = r.display_command();
983 let (s, e) = r.base_name_span().expect("span");
984 assert_eq!(&text[s..e], "grep");
985 }
986
987 /// Without `exe` (another user's process) fall back to argv[0].
988 #[test]
989 fn falls_back_to_argv0_without_an_exe() {
990 let r = row_with("/usr/bin/python3 script.py", None);
991 let text = r.display_command();
992 let (s, e) = r.base_name_span().expect("span");
993 assert_eq!(&text[s..e], "python3");
994 }
995
996 #[test]
997 fn a_bare_command_name_gets_no_span() {
998 // Nothing to emphasise: the name is the whole string already.
999 let r = row_with("launchd", None);
1000 assert_eq!(r.base_name_span(), None);
1001 }
1002
1003 #[test]
1004 fn a_kernel_thread_gets_no_span() {
1005 let r = row_with("[kworker/0:1]", None);
1006 assert_eq!(r.base_name_span(), None);
1007 }
1008
1009 #[test]
1010 fn an_empty_command_gets_no_span() {
1011 let mut r = Row::default_for_test(1);
1012 r.cmdline = None;
1013 r.comm.try_push_str("").unwrap();
1014 assert_eq!(r.base_name_span(), None);
1015 }
1016
1017 /// The renderer passes the *finished* column text, which in tree mode carries
1018 /// a branch prefix. The span has to move with it.
1019 #[test]
1020 fn the_span_tracks_a_prepended_tree_prefix() {
1021 let r = row_with("/usr/bin/nginx -g daemon", Some("/usr/bin/nginx"));
1022 let plain = r.display_command();
1023 let (s, e) = r.base_name_span().expect("span");
1024 assert_eq!(&plain[s..e], "nginx");
1025
1026 let prefixed = format!("├─ {plain}");
1027 let (ps, pe) = r.base_name_span_in(&prefixed).expect("span");
1028 assert_eq!(&prefixed[ps..pe], "nginx");
1029 assert_eq!(ps, s + "├─ ".len(), "offset by the prefix");
1030 }
1031
1032 /// A base name containing a space would make the argv[0] fallback wrong, but
1033 /// the exe path still locates it correctly.
1034 #[test]
1035 fn an_exe_with_spaces_is_located_by_path_not_by_word() {
1036 let r = row_with(
1037 "/Users/x/My App.app/Contents/MacOS/My App --flag",
1038 Some("/Users/x/My App.app/Contents/MacOS/My App"),
1039 );
1040 let text = r.display_command();
1041 let (s, e) = r.base_name_span().expect("span");
1042 assert_eq!(&text[s..e], "My App");
1043 }
1044}
1045
1046#[cfg(test)]
1047mod field_meta_tests {
1048 use super::*;
1049
1050 /// Every `Field` variant must have a `FieldMeta`.
1051 ///
1052 /// This is the guard for a real bug: `Session`, `Tty`, `Tpgid`, `StartTime`,
1053 /// `Tgid`, `SchedPolicy`, `ProcComm`, `ProcExe` and `Cwd` were declared as
1054 /// variants with no metadata, which made them unreachable in three ways at
1055 /// once — `name()` returned `"UNKNOWN"`, so an `htoprc` that named them
1056 /// imported to a column string that `TabConfig::fields` silently dropped;
1057 /// they never appeared in the setup screen, which iterates `Field::all()`; and
1058 /// the header title was `" ? "`. Nothing failed loudly, so it went unnoticed.
1059 ///
1060 /// The list below is written out rather than derived, because `Field` has no
1061 /// `strum`-style enumeration and adding a derive for one test is not worth the
1062 /// dependency. A new variant therefore requires editing this list too — which
1063 /// is the point: the test is a checklist, not an oracle.
1064 #[test]
1065 fn every_variant_has_metadata() {
1066 let all = [
1067 Field::Pid,
1068 Field::Comm,
1069 Field::State,
1070 Field::Ppid,
1071 Field::Pgrp,
1072 Field::Session,
1073 Field::Tty,
1074 Field::Tpgid,
1075 Field::MinFlt,
1076 Field::MajFlt,
1077 Field::Priority,
1078 Field::Nice,
1079 Field::StartTime,
1080 Field::Processor,
1081 Field::VirtMem,
1082 Field::ResMem,
1083 Field::User,
1084 Field::PercentCpu,
1085 Field::PercentMem,
1086 Field::Time,
1087 Field::Nlwp,
1088 Field::Tgid,
1089 Field::PercentNormCpu,
1090 Field::Elapsed,
1091 Field::SchedPolicy,
1092 Field::ProcComm,
1093 Field::ProcExe,
1094 Field::Cwd,
1095 ];
1096 #[allow(unused_mut)]
1097 let mut all = all.to_vec();
1098 #[cfg(target_os = "linux")]
1099 all.extend([
1100 Field::Pss,
1101 Field::Swap,
1102 Field::Private,
1103 Field::Epss,
1104 Field::Cgroup,
1105 Field::Container,
1106 Field::GpuPercent,
1107 Field::GpuTime,
1108 Field::CpuDelayPercent,
1109 Field::IoDelayPercent,
1110 Field::SwapDelayPercent,
1111 ]);
1112
1113 for field in all {
1114 let meta = field
1115 .meta()
1116 .unwrap_or_else(|| panic!("{field:?} has no FieldMeta"));
1117 assert_ne!(meta.name, "UNKNOWN", "{field:?} has no name");
1118 assert_ne!(meta.title.trim(), "?", "{field:?} has no header title");
1119 assert!(
1120 !meta.description.is_empty(),
1121 "{field:?} has no description for the setup screen"
1122 );
1123 // The name is the config key and the `--sort` argument, so it must
1124 // resolve back to the same field.
1125 assert_eq!(
1126 Field::from_name(meta.name),
1127 Some(field),
1128 "{field:?} does not round-trip through {}",
1129 meta.name
1130 );
1131 }
1132 }
1133
1134 /// The same check for `Field::all()`: a variant with metadata but not in the
1135 /// table is equally invisible, because the setup screen iterates the table.
1136 #[test]
1137 fn every_metadata_entry_is_offered_by_all() {
1138 let offered: Vec<Field> = Field::all().collect();
1139 for field in &offered {
1140 assert!(field.meta().is_some());
1141 }
1142 for wanted in [Field::Session, Field::Tty, Field::Cwd, Field::ProcComm] {
1143 assert!(
1144 offered.contains(&wanted),
1145 "{wanted:?} must be selectable in the setup screen"
1146 );
1147 }
1148 }
1149
1150 /// Names must be unique, or `from_name` would resolve to whichever came first.
1151 #[test]
1152 fn names_are_unique() {
1153 let mut seen = std::collections::HashSet::new();
1154 for field in Field::all() {
1155 assert!(seen.insert(field.name()), "duplicate name {}", field.name());
1156 }
1157 }
1158}