Skip to main content

gander_core/linux/
system.rs

1//! The Linux sampler: turns `/proc` into a `SystemSnapshot`.
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use crate::row::{CpuTicks, Row, RowFlags};
7use crate::system::{CpuTimes, DiskCounters, MemInfo, NetCounters, SwapInfo, SystemSnapshot};
8use crate::table::Cache;
9use crate::Pid;
10
11#[cfg(target_os = "linux")]
12use super::delayacct::{DelayAcct, DelayStats};
13use super::scan;
14use super::LinuxPlatform;
15
16/// Below this many processes, rayon's overhead exceeds the gain.
17const PAR_THRESHOLD: usize = 512;
18
19/// Drop the identity cache if it grows past this multiple of the live set.
20const CACHE_SLACK: usize = 4;
21
22/// Per-process state that must survive between ticks to compute deltas.
23#[derive(Copy, Clone)]
24struct PrevRow {
25    starttime: u64,
26    proc_ticks: u64,
27}
28
29pub struct Sampler {
30    root: PathBuf,
31    clk_tck: u64,
32    page_size: u64,
33    cache: Cache,
34    /// Previous aggregate + per-core counters, for delta computation.
35    prev_cpu: Vec<CpuTimes>,
36    /// Minimum per-process state needed for the next tick's delta.
37    prev_rows: HashMap<Pid, PrevRow>,
38    /// Previous host counters, for the network/disk throughput meters.
39    ///
40    /// Held as `Option` so the first sample can tell "no previous sample" from
41    /// "a previous sample that happened to be zero", which decides whether a
42    /// rate is meaningful or is simply 0 B/s.
43    prev_net: Option<(NetCounters, std::time::Instant)>,
44    prev_disk: Option<(DiskCounters, std::time::Instant)>,
45    pub use_rayon: bool,
46    /// Whether to emit one row per thread in addition to one per process.
47    ///
48    /// Off by default: `/proc/<pid>/task` costs an extra `stat` read per thread,
49    /// which on a busy desktop is roughly 1700 extra file reads per tick. htop
50    /// makes the same trade (`hide_userland_threads`).
51    show_threads: bool,
52    /// Whether a column needing `/proc/<pid>/smaps_rollup` is on screen.
53    ///
54    /// The kernel walks every VMA to produce that file, so reading it for every
55    /// process costs tens of milliseconds — more than the rest of the scan put
56    /// together. Set from the front end whenever the visible column set changes;
57    /// when false the sampler never opens the file at all.
58    read_smaps: bool,
59    /// Whether a GPU column is on screen (reads each process's `fdinfo`).
60    read_gpu: bool,
61    /// Whether a delay-accounting column is on screen.
62    ///
63    /// Costs one generic-netlink round trip per process per tick, which is more
64    /// expensive than a `/proc` read: every request is a syscall pair plus a
65    /// kernel lookup. Off unless a `CPUD%`/`IOD%`/`SWPD%` column is visible.
66    #[cfg(target_os = "linux")]
67    read_delayacct: bool,
68    /// The `TASKSTATS` socket, opened on first use.
69    ///
70    /// `Option` because opening it can fail in two ordinary ways (no
71    /// `CONFIG_TASKSTATS`, or the socket cannot be created), and the sampler must
72    /// keep working when it does. Opened lazily rather than in `new` so a run
73    /// that never shows a delay column never creates a socket.
74    #[cfg(target_os = "linux")]
75    delayacct: Option<DelayAcct>,
76    /// Previous per-task delay counters, for the percentage delta.
77    ///
78    /// Keyed by pid and holding `starttime` alongside: a recycled pid must not
79    /// inherit its predecessor's counters, the same hazard the CPU% path solves
80    /// with `PrevRow`.
81    #[cfg(target_os = "linux")]
82    prev_delay: HashMap<Pid, (u64, DelayStats)>,
83}
84
85impl Default for Sampler {
86    fn default() -> Self {
87        Self::new(Path::new("/proc"))
88    }
89}
90
91impl Sampler {
92    pub fn new(root: &Path) -> Self {
93        Self {
94            root: root.to_path_buf(),
95            clk_tck: LinuxPlatform::clk_tck(),
96            page_size: LinuxPlatform::page_size(),
97            cache: Cache::new(),
98            prev_cpu: Vec::new(),
99            prev_rows: HashMap::new(),
100            prev_net: None,
101            prev_disk: None,
102            use_rayon: true,
103            show_threads: false,
104            read_smaps: false,
105            read_gpu: false,
106            #[cfg(target_os = "linux")]
107            read_delayacct: false,
108            #[cfg(target_os = "linux")]
109            delayacct: None,
110            #[cfg(target_os = "linux")]
111            prev_delay: HashMap::new(),
112        }
113    }
114
115    /// Read delay counters for `pid`, opening the netlink socket on first use.
116    ///
117    /// Returns `None` when the data cannot be read: no `CAP_NET_ADMIN`, no
118    /// `CONFIG_TASKSTATS`, or the process exited between `readdir` and now. All
119    /// three are ordinary, and all three must render as `-` rather than `0.0%`.
120    ///
121    /// `starttime` is not used to compute anything; it is recorded so a recycled
122    /// pid can be detected. The lifetime percentage needs no delta, but a pid
123    /// reused by a new process must not keep the old one's numbers, and the
124    /// stored `starttime` is what makes that distinguishable.
125    #[cfg(target_os = "linux")]
126    fn delay_for(&mut self, pid: Pid, starttime: u64) -> Option<DelayStats> {
127        let acct = match self.delayacct {
128            Some(ref mut a) => a,
129            None => self.delayacct.insert(DelayAcct::open()?),
130        };
131        let stats = acct.read(pid)?;
132        // Forget counters for pids that have been reused, so the map cannot grow
133        // without bound and a recycled pid cannot inherit stale values.
134        if let Some(&(prev_start, _)) = self.prev_delay.get(&pid) {
135            if prev_start != starttime {
136                self.prev_delay.remove(&pid);
137            }
138        }
139        self.prev_delay.insert(pid, (starttime, stats));
140        Some(stats)
141    }
142
143    pub fn clk_tck(&self) -> u64 {
144        self.clk_tck
145    }
146
147    /// Sample everything: system counters plus one row per process.
148    fn sample_inner(&mut self) -> (SystemSnapshot, Vec<Row>) {
149        let at = std::time::Instant::now();
150
151        let cpu = scan::read_proc_stat(&self.root);
152        let cpu_delta: Vec<CpuTimes> = if self.prev_cpu.len() == cpu.len() && !cpu.is_empty() {
153            cpu.iter()
154                .zip(&self.prev_cpu)
155                .map(|(n, p)| n.delta(p))
156                .collect()
157        } else {
158            // First sample, or a core came online: no delta available yet.
159            vec![CpuTimes::default(); cpu.len()]
160        };
161
162        let mut mem = MemInfo::default();
163        let mut swap = SwapInfo::default();
164        scan::read_meminfo(&self.root, &mut mem, &mut swap);
165
166        let load = scan::read_loadavg(&self.root).unwrap_or([0.0; 3]);
167        let uptime = scan::read_uptime(&self.root).unwrap_or(0);
168
169        // Pressure stall information, zram devices, and the zswap pool ceiling.
170        // All three are Linux-only subsystems and all three are absent on kernels
171        // built without them, so each reports `None`/empty rather than zeroes that
172        // would render as an idle machine.
173        let psi = scan::read_psi(&self.root);
174        let zram = scan::read_zram(&self.root);
175        // `max_pool_percent` is a percentage of physical memory, so it can only be
176        // resolved once `MemTotal` is known.
177        if let Some(percent) = scan::read_zswap_max_percent(&self.root) {
178            mem.zswap_max = mem.total.saturating_mul(percent.min(100)) / 100;
179        }
180
181        // Network and disk throughput. Both are cumulative counters, so the rate
182        // needs the previous sample *and* the interval between the two; the
183        // interval is measured here rather than taken from the config, because a
184        // slow scan makes the real gap longer than the configured delay.
185        let net = scan::read_net_counters(&self.root);
186        let disk = scan::read_disk_counters(&self.root);
187        let net_rate = match self.prev_net {
188            Some((prev, at_prev)) => net.per_sec(&prev, at.duration_since(at_prev).as_secs_f64()),
189            None => crate::system::Rate::default(),
190        };
191        let disk_rate = match self.prev_disk {
192            Some((prev, at_prev)) => {
193                disk.per_sec(&prev, at.duration_since(at_prev).as_secs_f64())
194            }
195            None => crate::system::Rate::default(),
196        };
197        self.prev_net = Some((net, at));
198        self.prev_disk = Some((disk, at));
199
200        let pids = scan::list_pids(&self.root).unwrap_or_default();
201        let mut rows = self.read_rows(&pids, &cpu_delta, uptime, mem.total);
202        if self.show_threads {
203            let threads = self.read_threads(&pids, &cpu_delta);
204            rows.extend(threads);
205        }
206
207        self.prev_cpu = cpu.clone();
208        self.cache.retain_live(
209            &rows
210                .iter()
211                .map(|r| (r.pid, r.starttime))
212                .collect::<Vec<_>>(),
213        );
214        if self.cache.len() > rows.len().saturating_mul(CACHE_SLACK) + 64 {
215            self.cache.clear();
216        }
217
218        let snapshot = SystemSnapshot {
219            at,
220            cpu,
221            cpu_delta,
222            mem,
223            swap,
224            load,
225            uptime,
226            clk_tck: self.clk_tck,
227            page_size: self.page_size,
228            net,
229            net_rate,
230            disk,
231            disk_rate,
232            tasks: task_stats(&rows),
233            psi,
234            zram,
235        };
236        (snapshot, rows)
237    }
238
239    /// One extra row per thread, with `tgid` pointing at its process.
240    ///
241    /// The thread-group leader is skipped: it is already present as the process
242    /// row, and duplicating it would make the tree show every process twice.
243    /// Threads inherit the process's uid, command line and cgroup, so only the
244    /// thread's own `stat` (and therefore its CPU%) is read here.
245    fn read_threads(&mut self, pids: &[Pid], cpu_delta: &[CpuTimes]) -> Vec<Row> {
246        let root = self.root.clone();
247        let page_size = self.page_size;
248        let total_delta = cpu_delta.first().map(|d| d.total()).unwrap_or(0) as f32;
249
250        // Collect (pid, tid) pairs first so the parallel part only reads files.
251        let mut pairs: Vec<(Pid, Pid)> = Vec::new();
252        for &pid in pids {
253            let Ok(tids) = scan::list_tids(&root, pid) else {
254                continue;
255            };
256            for tid in tids {
257                if tid != pid {
258                    pairs.push((pid, tid));
259                }
260            }
261        }
262        if pairs.is_empty() {
263            return Vec::new();
264        }
265
266        let read_one = |&(pid, tid): &(Pid, Pid)| {
267            scan::read_thread_stat(&root, pid, tid).map(|(comm, fields)| (pid, tid, comm, fields))
268        };
269
270        let stats: Vec<(Pid, Pid, crate::row::CommBuf, super::parse::StatFields)> =
271            if self.use_rayon && pairs.len() >= PAR_THRESHOLD {
272                use rayon::prelude::*;
273                pairs.par_iter().filter_map(read_one).collect()
274            } else {
275                pairs.iter().filter_map(read_one).collect()
276            };
277
278        let mut out = Vec::with_capacity(stats.len());
279        for (pid, tid, comm, fields) in stats {
280            let starttime = fields.starttime;
281            let mut row = Row::default_for_test(tid);
282            row.pid = tid;
283            // The tid is already unique across the system on Linux, but it is
284            // stored through `thread_id` as well so that `Row::identity` means the
285            // same thing on both backends and a thread row can never be confused
286            // with a process that happens to have the same pid.
287            row.thread_id = Some(tid as u64);
288            row.tgid = pid;
289            row.is_thread = true;
290            row.ppid = pid;
291            row.comm = comm;
292
293            let prev_thread_ticks = match self.prev_rows.get(&tid) {
294                Some(p) if p.starttime == starttime => p.proc_ticks,
295                _ => 0,
296            };
297            self.prev_rows.insert(
298                tid,
299                PrevRow {
300                    starttime,
301                    proc_ticks: fields.cpu_ticks(),
302                },
303            );
304
305            scan::apply_stat(&mut row, &fields, page_size);
306
307            let delta = fields.cpu_ticks().saturating_sub(prev_thread_ticks) as f32;
308            row.percent_cpu = if total_delta > 0.0 {
309                delta * 100.0 / total_delta
310            } else {
311                0.0
312            };
313
314            // Identity is the process's, and already cached under the leader's pid.
315            let stable = scan::read_identity(&self.root, pid, starttime, &mut self.cache);
316            row.uid = stable.uid;
317            row.cmdline = stable.cmdline.clone();
318            row.exe = stable.exe.clone();
319            row.cwd = stable.cwd.clone();
320            row.cgroup = stable.cgroup.clone();
321            #[cfg(target_os = "linux")]
322            {
323                row.container = stable.container.clone();
324                row.container_runtime = stable.container_runtime.clone();
325            }
326            row.flags = row
327                .flags
328                .insert(RowFlags::USERLAND_THREAD)
329                .set(RowFlags::STALE_EXE, stable.exe_deleted);
330            row.nlwp = 1;
331
332            out.push(row);
333        }
334        out
335    }
336
337    /// Read `stat` for every pid, in parallel when the set is large enough.
338    fn read_rows(
339        &mut self,
340        pids: &[Pid],
341        cpu_delta: &[CpuTimes],
342        uptime: u64,
343        mem_total: u64,
344    ) -> Vec<Row> {
345        let root = &self.root;
346        let page_size = self.page_size;
347
348        // Phase 1: the per-process `stat` read. Read-only, so parallel-safe.
349        let stats: Vec<(Pid, crate::row::CommBuf, super::parse::StatFields)> =
350            if self.use_rayon && pids.len() >= PAR_THRESHOLD {
351                use rayon::prelude::*;
352                pids.par_iter()
353                    .filter_map(|&pid| scan::read_stat(root, pid).map(|(c, f)| (pid, c, f)))
354                    .collect()
355            } else {
356                pids.iter()
357                    .filter_map(|&pid| scan::read_stat(root, pid).map(|(c, f)| (pid, c, f)))
358                    .collect()
359            };
360
361        // Phase 2: identity (uid, cmdline, exe) — cached, so mostly a hash hit.
362        let mut out = Vec::with_capacity(stats.len());
363        let mut next_prev = HashMap::with_capacity(stats.len());
364        let total_delta = cpu_delta.first().map(|d| d.total()).unwrap_or(0) as f32;
365
366        for (pid, comm, fields) in stats {
367            let starttime = fields.starttime;
368            let mut row = Row::default_for_test(pid);
369
370            // Same (pid, starttime) means same process; otherwise this is either
371            // a new process or a reused PID and the tick history is invalid.
372            let prev_proc_ticks = match self.prev_rows.get(&pid) {
373                Some(p) if p.starttime == starttime => p.proc_ticks,
374                _ => 0,
375            };
376            next_prev.insert(
377                pid,
378                PrevRow {
379                    starttime,
380                    proc_ticks: fields.cpu_ticks(),
381                },
382            );
383
384            scan::apply_stat(&mut row, &fields, page_size);
385            row.comm = comm;
386
387            // The PSS family, only when the front end says a column needs it.
388            // `None` (not zero) when unread: another user's process refuses, and a
389            // zero would read as "this process uses no memory".
390            #[cfg(target_os = "linux")]
391            if self.read_smaps {
392                if let Some(rollup) = super::fsops::read_smaps_rollup(&self.root, pid) {
393                    row.pss = Some(rollup.pss);
394                    row.epss = Some(rollup.epss());
395                    row.private = Some(rollup.private());
396                    row.swap_bytes = Some(rollup.swap);
397                }
398            }
399            // GPU counters from `drm-usage-stats`, gated the same way and for the
400            // same reason: one `readdir` plus a read per descriptor, per process.
401            #[cfg(target_os = "linux")]
402            if self.read_gpu {
403                if let Some(gpu) = super::scan::read_gpu_usage(&self.root, pid) {
404                    row.gpu_percent = gpu.percent();
405                    row.gpu_ns = Some(gpu.gpu_ns);
406                }
407            }
408            // Delay accounting, over generic netlink. Also gated: a netlink round
409            // trip per process per tick is the most expensive read in the scan.
410            //
411            // The percentage is a lifetime average (the kernel keeps no interval
412            // counter), so it needs no delta: the first sample already has one.
413            #[cfg(target_os = "linux")]
414            if self.read_delayacct {
415                if let Some(stats) = self.delay_for(pid, starttime) {
416                    row.cpu_delay_percent = stats.cpu_percent();
417                    row.io_delay_percent = stats.blkio_percent();
418                    row.swap_delay_percent = stats.swapin_percent();
419                }
420            }
421
422            let d_proc = fields.cpu_ticks().saturating_sub(prev_proc_ticks) as f32;
423            row.percent_cpu = if total_delta > 0.0 {
424                // May exceed 100% on multicore: correct, and matches htop.
425                d_proc * 100.0 / total_delta
426            } else {
427                0.0
428            };
429
430            let stable = scan::read_identity(&self.root, pid, starttime, &mut self.cache);
431            row.uid = stable.uid;
432            row.flags = row
433                .flags
434                .set(RowFlags::KERNEL_THREAD, stable.is_kernel_thread);
435            row.cmdline = stable.cmdline;
436            row.exe = stable.exe;
437            row.cwd = stable.cwd;
438            row.cgroup = stable.cgroup;
439            // A deleted or replaced executable is highlighted, matching htop's EXE
440            // column behaviour. Threads inherit the process's exe, so they inherit
441            // the flag below too.
442            row.flags = row
443                .flags
444                .set(RowFlags::STALE_EXE, stable.exe_deleted);
445            #[cfg(target_os = "linux")]
446            {
447                row.container = stable.container;
448                row.container_runtime = stable.container_runtime;
449            }
450
451            row.elapsed = if self.clk_tck > 0 {
452                uptime.saturating_sub(starttime.checked_div(self.clk_tck).unwrap_or(0))
453            } else {
454                0
455            };
456            row.percent_mem = if mem_total > 0 {
457                row.res as f32 * 100.0 / mem_total as f32
458            } else {
459                0.0
460            };
461
462            row.ticks = CpuTicks {
463                proc: fields.cpu_ticks(),
464                total: total_delta as u64,
465            };
466            row.prev_ticks = CpuTicks {
467                proc: prev_proc_ticks,
468                total: 0,
469            };
470
471            out.push(row);
472        }
473
474        self.prev_rows = next_prev;
475        // Drop delay counters for processes that have exited. Without this the map
476        // grows with every process the machine has ever run, which on a desktop
477        // is thousands of entries in a few minutes.
478        #[cfg(target_os = "linux")]
479        {
480            let live: std::collections::HashSet<Pid> =
481                out.iter().filter(|r| !r.is_thread).map(|r| r.pid).collect();
482            self.prev_delay.retain(|pid, _| live.contains(pid));
483        }
484        out
485    }
486}
487
488/// Summarise the process table for the `Tasks` meter.
489///
490/// Only process rows are counted. Thread rows are extra rows for the same
491/// process, so counting every row would report `total` and `threads` roughly
492/// twice over: the leader's `nlwp` already counts its threads, and each thread
493/// row contributes one more.
494fn task_stats(rows: &[Row]) -> crate::system::TaskStats {
495    let procs = rows.iter().filter(|r| !r.is_thread);
496    let mut stats = crate::system::TaskStats::default();
497    for row in procs {
498        stats.total += 1;
499        if row.state == b'R' {
500            stats.running += 1;
501        }
502        stats.threads += row.nlwp.max(1) as usize;
503    }
504    stats
505}
506
507/// The unified sampler interface.
508///
509/// This was only implemented for the Darwin backend, so on Linux the trait was
510/// declared but never satisfied: `gander_core::PlatformSampler` described an
511/// interface that did not exist on the platform where it mattered most, and
512/// every `use gander_core::PlatformSampler` in a Linux-only test was dead
513/// (the inherent `sample` shadowed it). `sample` and `cache_len` now exist *only*
514/// on the trait, on both platforms, so a caller cannot accidentally depend on a
515/// method that one backend happens to have and the other does not.
516impl crate::PlatformSampler for Sampler {
517    fn sample(&mut self) -> (SystemSnapshot, Vec<Row>) {
518        self.sample_inner()
519    }
520
521    fn cache_len(&self) -> usize {
522        self.cache.len()
523    }
524
525    fn backend_name() -> &'static str {
526        "linux (/proc)"
527    }
528
529    fn set_scan_options(&mut self, opts: crate::ScanOptions) {
530        self.show_threads = opts.threads;
531        self.read_smaps = opts.smaps;
532        self.read_gpu = opts.gpu;
533        #[cfg(target_os = "linux")]
534        {
535            self.read_delayacct = opts.delayacct;
536            // Drop the socket when the last delay column goes away, so a session
537            // that showed one briefly does not hold a netlink socket open for the
538            // rest of the run.
539            if !opts.delayacct {
540                self.delayacct = None;
541                self.prev_delay.clear();
542            }
543        }
544    }
545
546    fn supports_threads() -> bool {
547        true
548    }
549}
550
551/// The Linux sampler must satisfy the shared trait. Gated to Linux because the
552/// `linux` module compiles on every unix (its IO layer is path-parameterised),
553/// but only Linux has a `/proc` to sample.
554#[cfg(all(test, target_os = "linux"))]
555mod platform_trait_tests {
556    use crate::PlatformSampler;
557
558    #[test]
559    fn the_linux_sampler_satisfies_the_shared_trait() {
560        assert!(<crate::Sampler as PlatformSampler>::backend_name().contains("linux"));
561        let mut s = crate::Sampler::default();
562        let (_snap, rows) = PlatformSampler::sample(&mut s);
563        assert!(!rows.is_empty(), "/proc must yield processes");
564        assert_eq!(PlatformSampler::cache_len(&s), <crate::Sampler>::cache_len(&s));
565    }
566}