Skip to main content

rigtop_core/
system.rs

1//! Portable statistics via the `sysinfo` crate: CPU load, memory, processes,
2//! network traffic and filesystem capacity.
3//!
4//! Platform-specific extras (sensors, RAID, per-device I/O) live in
5//! [`crate::linux`] and are merged by [`crate::source::LocalSource`]. On other
6//! platforms this module also provides per-volume I/O and the temperatures
7//! `sysinfo` can see.
8
9use std::collections::HashMap;
10
11use sysinfo::{
12    CpuRefreshKind, DiskRefreshKind, Disks, MemoryRefreshKind, Networks, ProcessRefreshKind,
13    ProcessesToUpdate, System, ThreadKind, UpdateKind, Users,
14};
15
16use crate::snapshot::{
17    CpuStats, FilesystemUsage, HostInfo, MemoryStats, NetworkStats, ProcessInfo,
18};
19#[cfg(not(target_os = "linux"))]
20use crate::snapshot::{DiskIo, SensorChip, Temperature};
21
22/// What to refresh for disks: capacity everywhere; per-volume I/O counters
23/// only where `/proc/diskstats` (per physical device) is not available.
24fn disk_refresh_kind() -> DiskRefreshKind {
25    let kind = DiskRefreshKind::nothing().with_storage();
26    if cfg!(target_os = "linux") {
27        kind
28    } else {
29        kind.with_io_usage()
30    }
31}
32
33/// Filesystem types that are never interesting in a disk panel.
34const PSEUDO_FILESYSTEMS: &[&str] = &[
35    "tmpfs",
36    "devtmpfs",
37    "squashfs",
38    "overlay",
39    "efivarfs",
40    "ramfs",
41    "proc",
42    "sysfs",
43    "cgroup2",
44    "fuse.portal",
45    "fuse.snapfuse",
46];
47
48/// Wraps the `sysinfo` handles that must persist between refreshes
49/// (CPU usage and rates are computed from differences).
50pub struct SystemCollector {
51    system: System,
52    networks: Networks,
53    disks: Disks,
54    users: Users,
55    /// Temperature sensors, where hwmon is not available.
56    #[cfg(not(target_os = "linux"))]
57    components: sysinfo::Components,
58}
59
60impl SystemCollector {
61    /// Creates the collector and takes an initial reading.
62    pub fn new() -> Self {
63        let mut collector = Self {
64            system: System::new(),
65            networks: Networks::new_with_refreshed_list(),
66            disks: Disks::new_with_refreshed_list_specifics(disk_refresh_kind()),
67            users: Users::new_with_refreshed_list(),
68            #[cfg(not(target_os = "linux"))]
69            components: sysinfo::Components::new_with_refreshed_list(),
70        };
71        collector.refresh();
72        collector
73    }
74
75    /// Refreshes everything this collector reports.
76    pub fn refresh(&mut self) {
77        self.system
78            .refresh_cpu_specifics(CpuRefreshKind::nothing().with_cpu_usage().with_frequency());
79        self.system
80            .refresh_memory_specifics(MemoryRefreshKind::everything());
81        self.system.refresh_processes_specifics(
82            ProcessesToUpdate::All,
83            true,
84            ProcessRefreshKind::nothing()
85                .with_cpu()
86                .with_memory()
87                .with_disk_usage()
88                // Tasks are on by default and make sysinfo refresh every
89                // thread as well (several /proc reads each), most of the
90                // collection time on busy desktops. Thread counts come from
91                // /proc/<pid>/stat on Linux instead.
92                .without_tasks()
93                .with_user(UpdateKind::OnlyIfNotSet)
94                .with_cmd(UpdateKind::OnlyIfNotSet),
95        );
96        self.networks.refresh(true);
97        self.disks.refresh_specifics(true, disk_refresh_kind());
98    }
99
100    /// Host name, OS and uptime.
101    pub fn host(&self) -> HostInfo {
102        HostInfo {
103            hostname: System::host_name().unwrap_or_default(),
104            os: System::long_os_version().unwrap_or_default(),
105            kernel: System::kernel_version().unwrap_or_default(),
106            uptime_secs: System::uptime(),
107            rigtop_version: env!("CARGO_PKG_VERSION").to_owned(),
108            reboot_required: None,
109        }
110    }
111
112    /// CPU load and frequencies. Temperatures are filled in by the caller.
113    pub fn cpu(&self) -> CpuStats {
114        let cpus = self.system.cpus();
115        let load = System::load_average();
116        CpuStats {
117            model: cpus
118                .first()
119                .map(|c| c.brand().trim().to_owned())
120                .unwrap_or_default(),
121            total_percent: self.system.global_cpu_usage(),
122            per_core_percent: cpus.iter().map(|c| c.cpu_usage()).collect(),
123            per_core_mhz: cpus.iter().map(|c| c.frequency()).collect(),
124            // Windows has no load average (sysinfo reports zeros).
125            load_average: cfg!(unix).then_some([load.one, load.five, load.fifteen]),
126            temperatures: Vec::new(),
127            per_core_celsius: Vec::new(),
128        }
129    }
130
131    /// Memory and swap. On Linux `cached_bytes` is filled in by the caller.
132    pub fn memory(&self) -> MemoryStats {
133        let s = &self.system;
134        // On macOS sysinfo's available memory is the kernel's memory-pressure
135        // headroom (active + inactive + free pages), which overlaps "used"
136        // (Activity Monitor's Memory Used). Show what is left of the total
137        // instead, as Activity Monitor does.
138        let available_bytes = if cfg!(target_os = "macos") {
139            s.total_memory().saturating_sub(s.used_memory())
140        } else {
141            s.available_memory()
142        };
143        #[cfg(target_os = "macos")]
144        let cached_bytes = macos_cached_bytes();
145        #[cfg(not(target_os = "macos"))]
146        let cached_bytes = None;
147        MemoryStats {
148            total_bytes: s.total_memory(),
149            used_bytes: s.used_memory(),
150            available_bytes,
151            free_bytes: s.free_memory(),
152            cached_bytes,
153            swap_total_bytes: s.total_swap(),
154            swap_used_bytes: s.used_swap(),
155            oom_kills: Vec::new(),
156            zfs_arc_bytes: None,
157        }
158    }
159
160    /// Network traffic per interface. `elapsed_secs` is the time since the
161    /// previous refresh; rates are zero when it is unknown.
162    pub fn networks(&self, elapsed_secs: Option<f64>) -> Vec<NetworkStats> {
163        let mut networks: Vec<NetworkStats> = self
164            .networks
165            .iter()
166            // Loopback traffic is local IPC, not network activity.
167            .filter(|(name, _)| name.as_str() != "lo")
168            .map(|(name, data)| NetworkStats {
169                name: name.clone(),
170                rx_bytes_per_sec: rate(data.received(), elapsed_secs),
171                tx_bytes_per_sec: rate(data.transmitted(), elapsed_secs),
172                rx_total_bytes: data.total_received(),
173                tx_total_bytes: data.total_transmitted(),
174                addresses: addresses(data.ip_networks()),
175                // Link state is platform-specific and filled in by the caller.
176                ..Default::default()
177            })
178            .collect();
179        networks.sort_by(|a, b| a.name.cmp(&b.name));
180        networks
181    }
182
183    /// Mounted filesystems, merged by source device so that btrfs subvolumes
184    /// and bind mounts appear once.
185    pub fn filesystems(&self) -> Vec<FilesystemUsage> {
186        let mut by_device: HashMap<String, FilesystemUsage> = HashMap::new();
187        for disk in self.disks.list() {
188            let fs_type = disk.file_system().to_string_lossy().into_owned();
189            if PSEUDO_FILESYSTEMS.contains(&fs_type.as_str()) || disk.total_space() == 0 {
190                continue;
191            }
192            let device = disk.name().to_string_lossy().into_owned();
193            let mount_point = disk.mount_point().to_string_lossy().into_owned();
194            // On Unix the name is the source device, which merges btrfs
195            // subvolumes and bind mounts. On Windows it is the volume label,
196            // often empty or shared, so each drive letter stands alone.
197            let key = if cfg!(windows) || device.is_empty() {
198                mount_point.clone()
199            } else {
200                device.clone()
201            };
202            let entry = by_device.entry(key).or_insert_with(|| FilesystemUsage {
203                device,
204                fs_type,
205                mount_points: Vec::new(),
206                total_bytes: disk.total_space(),
207                available_bytes: disk.available_space(),
208                full_in_secs: None,
209            });
210            entry.mount_points.push(mount_point);
211        }
212        let mut filesystems: Vec<FilesystemUsage> = by_device.into_values().collect();
213        for fs in &mut filesystems {
214            fs.mount_points.sort_by_key(|m| (m.len(), m.clone()));
215        }
216        // Order by the most prominent mount point ("/" first).
217        filesystems.sort_by(|a, b| {
218            let key = |f: &FilesystemUsage| f.mount_points.first().cloned().unwrap_or_default();
219            (key(a).len(), key(a)).cmp(&(key(b).len(), key(b)))
220        });
221        filesystems
222    }
223
224    /// I/O rates per mounted volume, for platforms without per-device
225    /// counters (Linux uses `/proc/diskstats` instead).
226    #[cfg(not(target_os = "linux"))]
227    pub fn volume_io(&self, elapsed_secs: Option<f64>) -> Vec<DiskIo> {
228        let mut volumes: Vec<DiskIo> = self
229            .disks
230            .list()
231            .iter()
232            .filter(|disk| disk.total_space() > 0)
233            .map(|disk| {
234                let usage = disk.usage();
235                // "C:\" -> "C:"; elsewhere the mount point as is.
236                let mount = disk.mount_point().to_string_lossy();
237                let name = mount.trim_end_matches(['\\', '/']);
238                DiskIo {
239                    name: if name.is_empty() {
240                        mount.to_string()
241                    } else {
242                        name.to_owned()
243                    },
244                    read_bytes_per_sec: rate(usage.read_bytes, elapsed_secs),
245                    write_bytes_per_sec: rate(usage.written_bytes, elapsed_secs),
246                    ..Default::default()
247                }
248            })
249            .collect();
250        volumes.sort_by(|a, b| a.name.cmp(&b.name));
251        volumes.dedup_by(|a, b| a.name == b.name);
252        volumes
253    }
254
255    /// Temperatures `sysinfo` can read (on Windows only what WMI exposes,
256    /// often nothing without vendor drivers), as one sensor chip plus the
257    /// readings that look like CPU sensors.
258    #[cfg(not(target_os = "linux"))]
259    pub fn components(&mut self) -> (Vec<SensorChip>, Vec<Temperature>) {
260        self.components.refresh(true);
261        let temperatures: Vec<Temperature> = self
262            .components
263            .list()
264            .iter()
265            .filter_map(|c| {
266                Some(Temperature {
267                    key: format!("system/{}", c.label()),
268                    label: c.label().to_owned(),
269                    celsius: c.temperature()?,
270                    max_celsius: c.max(),
271                    critical_celsius: c.critical(),
272                })
273            })
274            .collect();
275        let cpu: Vec<Temperature> = temperatures
276            .iter()
277            .filter(|t| is_cpu_sensor_label(&t.label))
278            .cloned()
279            .collect();
280        let chips = if temperatures.is_empty() {
281            Vec::new()
282        } else {
283            vec![SensorChip {
284                key: "system".to_owned(),
285                name: "system".to_owned(),
286                temperatures,
287                fans: Vec::new(),
288            }]
289        };
290        (chips, cpu)
291    }
292
293    /// All processes (threads excluded). `elapsed_secs` converts the I/O
294    /// byte counts since the previous refresh into rates.
295    pub fn processes(&mut self, elapsed_secs: Option<f64>) -> Vec<ProcessInfo> {
296        // Resolve uids lazily: refresh the user list only when an unknown uid shows up
297        // (e.g. a user created after rigtop started).
298        let unknown_uid = self.system.processes().values().any(|p| {
299            p.user_id()
300                .is_some_and(|uid| self.users.get_user_by_id(uid).is_none())
301        });
302        if unknown_uid {
303            self.users.refresh();
304        }
305
306        self.system
307            .processes()
308            .values()
309            // With tasks refreshed, sysinfo would also list each thread as
310            // an entry; make sure none slip in. Kernel threads (kworker,
311            // ...) are kept and flagged, like btop does.
312            .filter(|p| p.thread_kind() != Some(ThreadKind::Userland))
313            .map(|p| {
314                let disk = p.disk_usage();
315                let user = p
316                    .user_id()
317                    .map(|uid| {
318                        self.users
319                            .get_user_by_id(uid)
320                            .map(|u| u.name().to_owned())
321                            .unwrap_or_else(|| uid.to_string())
322                    })
323                    .unwrap_or_default();
324                let command = p
325                    .cmd()
326                    .iter()
327                    .map(|arg| arg.to_string_lossy())
328                    .collect::<Vec<_>>()
329                    .join(" ");
330                let name = p.name().to_string_lossy();
331                let leading: Vec<_> = p
332                    .cmd()
333                    .iter()
334                    .take(2)
335                    .map(|a| a.to_string_lossy())
336                    .collect();
337                let leading: Vec<&str> = leading.iter().map(|a| a.as_ref()).collect();
338                ProcessInfo {
339                    pid: p.pid().as_u32(),
340                    parent_pid: p.parent().map(|pid| pid.as_u32()),
341                    name: full_name(&name, &leading),
342                    command,
343                    user,
344                    state: p.status().to_string(),
345                    cpu_percent: p.cpu_usage(),
346                    memory_bytes: p.memory(),
347                    // Filled in from /proc/<pid>/stat on Linux; sysinfo has
348                    // no count without refreshing every thread.
349                    threads: None,
350                    // Also from /proc/<pid>/stat on Linux.
351                    nice: None,
352                    read_bytes_per_sec: rate(disk.read_bytes, elapsed_secs),
353                    write_bytes_per_sec: rate(disk.written_bytes, elapsed_secs),
354                    run_time_secs: p.run_time(),
355                    kernel_thread: p.thread_kind() == Some(ThreadKind::Kernel),
356                    // Filled in from /proc/<pid>/cgroup on Linux.
357                    container: None,
358                }
359            })
360            .collect()
361    }
362}
363
364impl Default for SystemCollector {
365    fn default() -> Self {
366        Self::new()
367    }
368}
369
370/// Whether a sensor label from `sysinfo` describes the CPU (e.g. "CPU",
371/// "CPU Package", "Tctl", "Core 0").
372#[cfg_attr(target_os = "linux", allow(dead_code))]
373fn is_cpu_sensor_label(label: &str) -> bool {
374    let label = label.to_lowercase();
375    ["cpu", "package", "tctl", "tdie", "tccd", "core "]
376        .iter()
377        .any(|word| label.contains(word))
378}
379
380/// File-backed pages on macOS (Activity Monitor's "Cached Files"), the
381/// counterpart of Linux's page cache; `None` if the kernel does not answer.
382#[cfg(target_os = "macos")]
383fn macos_cached_bytes() -> Option<u64> {
384    use std::sync::OnceLock;
385
386    // Every mach_host_self() call adds a reference to the port, so keep one.
387    static HOST: OnceLock<libc::mach_port_t> = OnceLock::new();
388    // SAFETY: mach_host_self takes no arguments and returns a port name.
389    #[allow(deprecated)]
390    let host = *HOST.get_or_init(|| unsafe { libc::mach_host_self() });
391
392    // SAFETY: an all-zero vm_statistics64 is valid (plain integers).
393    let mut stats: libc::vm_statistics64 = unsafe { std::mem::zeroed() };
394    let mut count = libc::HOST_VM_INFO64_COUNT;
395    // SAFETY: `stats` is a vm_statistics64 and `count` its size in integers,
396    // as HOST_VM_INFO64 requires.
397    let result = unsafe {
398        libc::host_statistics64(
399            host,
400            libc::HOST_VM_INFO64,
401            (&mut stats as *mut libc::vm_statistics64).cast(),
402            &mut count,
403        )
404    };
405    if result != libc::KERN_SUCCESS {
406        return None;
407    }
408    // SAFETY: sysconf has no preconditions.
409    let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
410    let page_size = u64::try_from(page_size).ok().filter(|&size| size > 0)?;
411    Some(u64::from(stats.external_page_count) * page_size)
412}
413
414/// Converts a byte count accumulated over `elapsed_secs` into bytes per second.
415fn rate(bytes: u64, elapsed_secs: Option<f64>) -> f64 {
416    match elapsed_secs {
417        Some(secs) if secs > 0.0 => bytes as f64 / secs,
418        _ => 0.0,
419    }
420}
421
422/// Longest process name Linux keeps (`TASK_COMM_LEN` minus the NUL);
423/// longer ones are cut, e.g. `gnome-keyring-d` for `gnome-keyring-daemon`.
424const COMM_LEN: usize = 15;
425
426/// The name to show for a process: its kernel name, unless that is cut
427/// short (Linux keeps 15 bytes) or a thread name its program set for its
428/// main thread (`MainThread` in Node.js), where the file name of the
429/// program in its command line says more. `leading` is the start of the
430/// command line: the program, then (for a script) the script.
431fn full_name(name: &str, leading: &[&str]) -> String {
432    let file = |arg: &str| {
433        arg.rsplit(['/', '\\'])
434            .next()
435            .unwrap_or_default()
436            .trim()
437            .to_owned()
438    };
439    if name == "MainThread"
440        && let Some(program) = leading.first().map(|a| file(a)).filter(|f| !f.is_empty())
441    {
442        return program;
443    }
444    // A script's name is the kernel name too: `python3 /usr/bin/networkd-dispatcher`.
445    if cfg!(target_os = "linux") && name.len() == COMM_LEN {
446        for arg in leading {
447            let file = file(arg);
448            if file.len() > name.len() && file.starts_with(name) {
449                return file;
450            }
451        }
452    }
453    name.to_owned()
454}
455
456/// An interface's addresses as text ("192.168.1.10/24"), IPv4 first and
457/// without IPv6 link-local ones (every interface has one; they are of no
458/// use elsewhere).
459fn addresses(networks: &[sysinfo::IpNetwork]) -> Vec<String> {
460    let mut found: Vec<&sysinfo::IpNetwork> = networks
461        .iter()
462        .filter(|n| match n.addr {
463            std::net::IpAddr::V4(_) => true,
464            // fe80::/10
465            std::net::IpAddr::V6(v6) => v6.segments()[0] & 0xffc0 != 0xfe80,
466        })
467        .collect();
468    found.sort_by_key(|n| (n.addr.is_ipv6(), n.addr));
469    found.dedup_by_key(|n| n.addr);
470    found
471        .into_iter()
472        .map(|n| format!("{}/{}", n.addr, n.prefix))
473        .collect()
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    #[test]
481    fn names_cut_short_or_renamed_take_the_program_file() {
482        let linux = cfg!(target_os = "linux");
483        let cut = full_name(
484            "gnome-keyring-d",
485            &["/usr/bin/gnome-keyring-daemon", "--start"],
486        );
487        assert_eq!(
488            cut,
489            if linux {
490                "gnome-keyring-daemon"
491            } else {
492                "gnome-keyring-d"
493            }
494        );
495        let script = full_name(
496            "networkd-dispat",
497            &["/usr/bin/python3", "/usr/bin/networkd-dispatcher"],
498        );
499        assert_eq!(
500            script,
501            if linux {
502                "networkd-dispatcher"
503            } else {
504                "networkd-dispat"
505            }
506        );
507        assert_eq!(
508            full_name("MainThread", &["/usr/local/bin/node", "server.js"]),
509            "node"
510        );
511        // A name that fits stays, whatever the command line says.
512        assert_eq!(full_name("python3", &["/usr/bin/python3.12"]), "python3");
513        // Programs that rewrite their command line (setproctitle) keep the
514        // kernel name: the line no longer starts with the program.
515        assert_eq!(
516            full_name(
517                "Isolated Web Co",
518                &["/usr/lib/firefox/firefox", "-contentproc"]
519            ),
520            "Isolated Web Co"
521        );
522        assert_eq!(
523            full_name("postgres", &["postgres: checkpointer"]),
524            "postgres"
525        );
526        // Kernel threads have no command line.
527        assert_eq!(full_name("kworker/0:1H-ev", &[]), "kworker/0:1H-ev");
528        assert_eq!(full_name("MainThread", &[]), "MainThread");
529    }
530
531    #[test]
532    fn cpu_sensor_labels() {
533        for label in [
534            "CPU",
535            "CPU Package",
536            "Tctl",
537            "Core 3",
538            "coretemp Package id 0",
539        ] {
540            assert!(is_cpu_sensor_label(label), "{label}");
541        }
542        for label in ["GPU", "nvme Composite", "acpitz"] {
543            assert!(!is_cpu_sensor_label(label), "{label}");
544        }
545    }
546
547    #[cfg(target_os = "macos")]
548    #[test]
549    fn macos_memory_adds_up() {
550        let memory = SystemCollector::new().memory();
551        assert!(memory.total_bytes > 0);
552        assert!(memory.used_bytes + memory.available_bytes <= memory.total_bytes);
553        let cached = memory.cached_bytes.expect("host_statistics64 answers");
554        assert!(cached > 0 && cached < memory.total_bytes, "{cached}");
555    }
556}