Skip to main content

rigtop_core/
source.rs

1//! Data sources: anything that can produce [`Snapshot`]s.
2//!
3//! Front-ends only talk to the [`DataSource`] trait. The local machine is one
4//! implementation ([`LocalSource`]); a remote host reached through
5//! `ssh host rigtop --agent` will be another, so remote monitoring needs no
6//! changes in the front-ends.
7
8use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
9
10use crate::config::Config;
11use crate::gpu::NvmlCollector;
12use crate::smart::SmartReader;
13#[cfg(target_os = "linux")]
14use crate::snapshot::device_matches_disk;
15use crate::snapshot::{PowerStats, Snapshot};
16use crate::system::SystemCollector;
17
18#[cfg(target_os = "linux")]
19use crate::linux::{
20    self, SysRoots, diskstats::DiskStatsMeter, net::NetLinkReader, rapl::RaplMeter,
21};
22
23/// Shortest sensible interval between two polls. CPU usage is computed from
24/// the difference between polls and is inaccurate below this.
25pub const MIN_POLL_INTERVAL: Duration = sysinfo::MINIMUM_CPU_UPDATE_INTERVAL;
26
27/// Errors a data source can report.
28#[derive(Debug, thiserror::Error)]
29pub enum SourceError {
30    /// Reading from the underlying channel failed.
31    #[error("I/O error: {0}")]
32    Io(#[from] std::io::Error),
33    /// The source is no longer available (e.g. the SSH connection closed).
34    #[error("source disconnected: {0}")]
35    Disconnected(String),
36    /// The remote agent reported a problem or does not speak our protocol.
37    #[error("remote: {0}")]
38    Remote(String),
39    /// Sending a signal to a process failed (no such process, permission denied, ...).
40    #[error("cannot signal process {pid}: {source}")]
41    Signal {
42        /// Target process id.
43        pid: u32,
44        /// Underlying OS error.
45        source: std::io::Error,
46    },
47}
48
49/// Signals a front-end can send to a process.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
51pub enum ProcessSignal {
52    /// Ask the process to exit (SIGTERM).
53    Terminate,
54    /// Force the process to exit (SIGKILL).
55    Kill,
56}
57
58/// A producer of snapshots, local or remote.
59pub trait DataSource {
60    /// Takes a new measurement.
61    ///
62    /// Rates (CPU %, throughput, power) cover the time since the previous
63    /// call, so callers should poll at a steady interval of at least
64    /// [`MIN_POLL_INTERVAL`].
65    fn poll(&mut self) -> Result<Snapshot, SourceError>;
66
67    /// Sends `signal` to process `pid` on the monitored host, with the
68    /// privileges of the user the source runs as.
69    fn signal_process(&mut self, pid: u32, signal: ProcessSignal) -> Result<(), SourceError>;
70
71    /// Tells the source how often it will be polled. Remote sources forward
72    /// this to the agent, which samples on its own schedule; local sources
73    /// need nothing.
74    fn set_interval(&mut self, _interval: Duration) {}
75
76    /// Limits snapshots to the top `limit` processes by CPU (`None`: all).
77    /// Remote sources use this to save bandwidth for hosts in an overview.
78    fn set_process_limit(&mut self, _limit: Option<usize>) {}
79
80    /// Processes to always include in snapshots, whatever the process
81    /// limit, and to list in [`Snapshot::watched_pids`], so that the client
82    /// can tell when they exit. Replaces the previous list.
83    fn watch_processes(&mut self, _pids: Vec<u32>) {}
84
85    /// Like [`DataSource::watch_processes`] for every process matching one
86    /// of `names` (see [`crate::view::process_matches`]) while it runs, and
87    /// in the snapshot after it exited, so that the client sees it go.
88    /// Replaces the previous list.
89    fn watch_process_names(&mut self, _names: Vec<String>) {}
90}
91
92/// Boxed sources, so front-ends can choose local or remote at run time.
93impl<T: DataSource + ?Sized> DataSource for Box<T> {
94    fn poll(&mut self) -> Result<Snapshot, SourceError> {
95        (**self).poll()
96    }
97
98    fn signal_process(&mut self, pid: u32, signal: ProcessSignal) -> Result<(), SourceError> {
99        (**self).signal_process(pid, signal)
100    }
101
102    fn set_interval(&mut self, interval: Duration) {
103        (**self).set_interval(interval);
104    }
105
106    fn set_process_limit(&mut self, limit: Option<usize>) {
107        (**self).set_process_limit(limit);
108    }
109
110    fn watch_processes(&mut self, pids: Vec<u32>) {
111        (**self).watch_processes(pids);
112    }
113
114    fn watch_process_names(&mut self, names: Vec<String>) {
115        (**self).watch_process_names(names);
116    }
117}
118
119/// Collects snapshots of the machine rigtop runs on.
120pub struct LocalSource {
121    system: SystemCollector,
122    nvml: NvmlCollector,
123    smart: SmartReader,
124    last_poll: Option<Instant>,
125    /// See [`DataSource::watch_processes`].
126    watched_pids: Vec<u32>,
127    /// See [`DataSource::watch_process_names`], and the pids they matched
128    /// in the last snapshot.
129    watched_names: Vec<String>,
130    name_matches: Vec<u32>,
131    fill: crate::fill::FillTracker,
132    #[cfg(target_os = "linux")]
133    roots: SysRoots,
134    #[cfg(target_os = "linux")]
135    rapl: RaplMeter,
136    #[cfg(target_os = "linux")]
137    diskstats: DiskStatsMeter,
138    #[cfg(target_os = "linux")]
139    net_links: NetLinkReader,
140    #[cfg(target_os = "linux")]
141    failed_units: linux::systemd::FailedUnitsReader,
142    #[cfg(target_os = "linux")]
143    containers: linux::containers::ContainerTracker,
144    #[cfg(target_os = "linux")]
145    oom: linux::oom::OomTracker,
146    /// Package and core id of each logical core, read again only when the
147    /// number of cores changes.
148    #[cfg(target_os = "linux")]
149    core_locations: Vec<Option<linux::hwmon::CoreLocation>>,
150    /// Directories searched for btrfs scrub status files, in order.
151    #[cfg(target_os = "linux")]
152    scrub_dirs: Vec<std::path::PathBuf>,
153    /// AMD and Intel GPUs (NVIDIA ones come from NVML).
154    #[cfg(target_os = "linux")]
155    drm_gpus: linux::drm::DrmGpuReader,
156    /// Temperatures, fans and CPU power from LibreHardwareMonitor.
157    #[cfg(windows)]
158    lhm: crate::lhm::LhmReader,
159}
160
161impl LocalSource {
162    /// Creates a source with the default configuration. See [`LocalSource::with_config`].
163    pub fn new() -> Self {
164        Self::with_config(&Config::default())
165    }
166
167    /// Creates a source and takes a warm-up sample, so that the first
168    /// [`DataSource::poll`] (after at least [`MIN_POLL_INTERVAL`]) already
169    /// has rates.
170    ///
171    /// Only collection-related settings (the SMART directory) are used here;
172    /// display settings such as sensor labels are applied by front-ends.
173    pub fn with_config(config: &Config) -> Self {
174        let mut source = Self {
175            system: SystemCollector::new(),
176            nvml: NvmlCollector::new(),
177            smart: SmartReader::new(&config.smart.dir),
178            last_poll: None,
179            watched_pids: Vec::new(),
180            watched_names: Vec::new(),
181            name_matches: Vec::new(),
182            fill: crate::fill::FillTracker::new(),
183            #[cfg(target_os = "linux")]
184            roots: SysRoots::default(),
185            #[cfg(target_os = "linux")]
186            rapl: RaplMeter::new(),
187            #[cfg(target_os = "linux")]
188            diskstats: DiskStatsMeter::new(),
189            #[cfg(target_os = "linux")]
190            net_links: NetLinkReader::new(),
191            #[cfg(target_os = "linux")]
192            failed_units: linux::systemd::FailedUnitsReader::new(),
193            #[cfg(target_os = "linux")]
194            containers: linux::containers::ContainerTracker::new(),
195            #[cfg(target_os = "linux")]
196            oom: linux::oom::OomTracker::new(),
197            #[cfg(target_os = "linux")]
198            core_locations: Vec::new(),
199            #[cfg(target_os = "linux")]
200            drm_gpus: linux::drm::DrmGpuReader::new(),
201            // The copy made by the root collector first, then btrfs-progs'
202            // own file in case rigtop runs as root.
203            #[cfg(windows)]
204            lhm: crate::lhm::LhmReader::new(&config.lhm.url),
205            #[cfg(target_os = "linux")]
206            scrub_dirs: vec![
207                config.btrfs.scrub_dir.clone(),
208                linux::btrfs::BTRFS_PROGS_STATUS_DIR.into(),
209            ],
210        };
211        // The warm-up result itself is meaningless (no previous sample).
212        let _ = source.collect();
213        source
214    }
215
216    /// Why NVIDIA GPUs are unavailable, if NVML failed to initialize.
217    pub fn gpu_unavailable_reason(&self) -> Option<&str> {
218        self.nvml.init_error()
219    }
220
221    fn collect(&mut self) -> Snapshot {
222        let now = Instant::now();
223        let elapsed = self
224            .last_poll
225            .map(|prev| now.saturating_duration_since(prev).as_secs_f64());
226        self.last_poll = Some(now);
227
228        self.system.refresh();
229        let processes = self.system.processes(elapsed);
230        let gpus = self.nvml.collect();
231        let gpus_w = sum_present(gpus.iter().map(|g| g.power_w));
232
233        let mut snapshot = Snapshot {
234            timestamp_ms: SystemTime::now()
235                .duration_since(UNIX_EPOCH)
236                .map(|d| d.as_millis() as u64)
237                .unwrap_or(0),
238            host: self.system.host(),
239            cpu: self.system.cpu(),
240            memory: self.system.memory(),
241            networks: self.system.networks(elapsed),
242            filesystems: self.system.filesystems(),
243            process_count: processes.len(),
244            processes,
245            watched_pids: self.watched_pids.clone(),
246            gpus,
247            smart: self.smart.read(),
248            power: PowerStats {
249                gpus_w,
250                ..Default::default()
251            },
252            ..Default::default()
253        };
254
255        #[cfg(target_os = "linux")]
256        self.collect_linux(&mut snapshot, now);
257        #[cfg(not(target_os = "linux"))]
258        self.collect_portable(&mut snapshot, elapsed);
259
260        self.fill.annotate(&mut snapshot.filesystems, now);
261        if !(self.watched_names.is_empty() && self.name_matches.is_empty()) {
262            self.name_matches =
263                report_name_matches(&self.watched_names, &self.name_matches, &mut snapshot);
264        }
265        let power = &mut snapshot.power;
266        power.total_w = sum_present([power.cpu_or_soc_w().1, power.gpus_w]);
267        snapshot
268    }
269
270    /// Adds what `sysinfo` offers beyond the basics on non-Linux platforms:
271    /// per-volume I/O, temperatures and (on Windows) link speeds.
272    #[cfg(not(target_os = "linux"))]
273    fn collect_portable(&mut self, snapshot: &mut Snapshot, elapsed: Option<f64>) {
274        snapshot.disks = self.system.volume_io(elapsed);
275        let (chips, cpu_temperatures) = self.system.components();
276        snapshot.sensors = chips;
277        snapshot.cpu.temperatures = cpu_temperatures;
278        #[cfg(windows)]
279        {
280            crate::windows::annotate_links(&mut snapshot.networks);
281            // LibreHardwareMonitor, when running, knows far more than the
282            // ACPI thermal zones sysinfo can see.
283            match self.lhm.read(Instant::now()) {
284                Some(lhm) => {
285                    if !lhm.chips.is_empty() {
286                        snapshot.sensors = lhm.chips;
287                    }
288                    if !lhm.cpu_temperatures.is_empty() {
289                        snapshot.cpu.temperatures = lhm.cpu_temperatures;
290                    }
291                    snapshot.power.cpu_package_w = lhm.cpu_package_w;
292                    if !lhm.smart.is_empty() {
293                        snapshot.smart = lhm.smart;
294                    }
295                }
296                None => snapshot.lhm_error = self.lhm.problem().map(str::to_owned),
297            }
298        }
299    }
300
301    /// Adds sensors, power, per-device I/O and RAID state from `/sys` and `/proc`.
302    #[cfg(target_os = "linux")]
303    fn collect_linux(&mut self, snapshot: &mut Snapshot, now: Instant) {
304        let chips = linux::hwmon::read_chips(&self.roots);
305
306        let cores = snapshot.cpu.per_core_percent.len();
307        if self.core_locations.len() != cores {
308            self.core_locations = linux::hwmon::read_core_locations(&self.roots, cores);
309        }
310        (snapshot.cpu.temperatures, snapshot.cpu.per_core_celsius) =
311            linux::hwmon::split_core_temperatures(&chips, &self.core_locations);
312        snapshot.memory.cached_bytes = linux::meminfo::read_cached_bytes(&self.roots);
313        snapshot.memory.oom_kills =
314            self.oom
315                .sample(&self.roots, &snapshot.processes, snapshot.timestamp_ms);
316        snapshot.power.cpu_package_w = self.rapl.sample(&self.roots, now);
317        // The APU's amdgpu power covers the whole SoC (see PowerStats).
318        // Discrete AMD GPUs are not reported yet.
319        snapshot.power.igpu_w = sum_present(
320            chips
321                .iter()
322                .filter(|c| c.is_apu)
323                .flat_map(|c| c.power_w.iter().map(|(_, w)| Some(*w))),
324        );
325        snapshot.power.battery = linux::battery::read_battery(&self.roots);
326
327        // AMD and Intel GPUs after NVIDIA's; discrete ones add to GPU power.
328        let first = snapshot.gpus.len() as u32;
329        snapshot.gpus.extend(self.drm_gpus.read(&self.roots, first));
330        snapshot.power.gpus_w = sum_present(snapshot.gpus.iter().map(|g| g.power_w));
331
332        let mut disks = self.diskstats.sample(&self.roots, now);
333        for disk in &mut disks {
334            disk.temperature_c = nvme_temperature(&chips, &disk.name);
335        }
336        snapshot.disks = disks;
337
338        let mounts = linux::mounts::read_mounts(&self.roots);
339        snapshot.raid.btrfs = linux::btrfs::read_filesystems(&self.roots, &mounts);
340        for fs in &mut snapshot.raid.btrfs {
341            fs.scrub = linux::btrfs::read_scrub(&self.scrub_dirs, &fs.fsid);
342        }
343        self.net_links
344            .annotate(&self.roots, &mut snapshot.networks, now);
345        snapshot.raid.md = linux::mdstat::read_arrays(&self.roots);
346        snapshot.raid.zfs = linux::zfs::read_pools(&self.roots);
347        snapshot.failed_units = self.failed_units.read(now);
348        snapshot.pressure = linux::pressure::read_pressure(&self.roots);
349        let release = std::fs::read_to_string(self.roots.proc.join("sys/kernel/osrelease"))
350            .unwrap_or_default();
351        snapshot.host.reboot_required = linux::reboot::reboot_required(
352            std::path::Path::new("/run"),
353            std::path::Path::new("/usr/lib/modules"),
354            &release,
355        );
356        // sysinfo no longer lists threads (see SystemCollector::refresh).
357        for process in &mut snapshot.processes {
358            process.threads = linux::procstat::thread_count(&self.roots, process.pid);
359        }
360        self.containers
361            .annotate(&self.roots, &mut snapshot.processes, now);
362
363        snapshot.sensors = chips.into_iter().map(|c| c.chip).collect();
364    }
365}
366
367impl Default for LocalSource {
368    fn default() -> Self {
369        Self::new()
370    }
371}
372
373impl DataSource for LocalSource {
374    fn poll(&mut self) -> Result<Snapshot, SourceError> {
375        Ok(self.collect())
376    }
377
378    fn signal_process(&mut self, pid: u32, signal: ProcessSignal) -> Result<(), SourceError> {
379        send_signal(pid, signal).map_err(|source| SourceError::Signal { pid, source })
380    }
381
382    /// With a limit, only the top processes are shown (overview), which do
383    /// not include per-process GPU utilization: skip that NVML query.
384    fn set_process_limit(&mut self, limit: Option<usize>) {
385        self.nvml.set_process_utilization(limit.is_none());
386    }
387
388    fn watch_processes(&mut self, pids: Vec<u32>) {
389        self.watched_pids = pids;
390    }
391
392    fn watch_process_names(&mut self, names: Vec<String>) {
393        self.watched_names = names;
394    }
395}
396
397/// Adds the processes of `snapshot` matching `names` (see
398/// [`DataSource::watch_process_names`]) to its `watched_pids`, and once
399/// more those of `previous` (last time's matches) that are gone, so the
400/// client sees them exit. Returns this time's matches.
401fn report_name_matches(names: &[String], previous: &[u32], snapshot: &mut Snapshot) -> Vec<u32> {
402    let matches: Vec<u32> = snapshot
403        .processes
404        .iter()
405        .filter(|p| {
406            !p.kernel_thread
407                && names
408                    .iter()
409                    .any(|name| crate::view::process_matches(p, name))
410        })
411        .map(|p| p.pid)
412        .collect();
413    let gone = previous
414        .iter()
415        .filter(|pid| !snapshot.processes.iter().any(|p| p.pid == **pid));
416    for pid in matches.iter().chain(gone) {
417        if !snapshot.watched_pids.contains(pid) {
418            snapshot.watched_pids.push(*pid);
419        }
420    }
421    matches
422}
423
424/// Sends a signal to a local process.
425#[cfg(unix)]
426fn send_signal(pid: u32, signal: ProcessSignal) -> std::io::Result<()> {
427    // pid 0 and negative values address process groups in kill(2); never
428    // let a bad pid turn into "signal everything".
429    let pid = libc::pid_t::try_from(pid)
430        .ok()
431        .filter(|p| *p > 0)
432        .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
433    let sig = match signal {
434        ProcessSignal::Terminate => libc::SIGTERM,
435        ProcessSignal::Kill => libc::SIGKILL,
436    };
437    // SAFETY: kill(2) has no memory-safety preconditions.
438    if unsafe { libc::kill(pid, sig) } == 0 {
439        Ok(())
440    } else {
441        Err(std::io::Error::last_os_error())
442    }
443}
444
445#[cfg(windows)]
446use crate::windows::send_signal;
447
448/// Sends a signal to a local process (not implemented on this platform yet).
449#[cfg(not(any(unix, windows)))]
450fn send_signal(_pid: u32, _signal: ProcessSignal) -> std::io::Result<()> {
451    Err(std::io::Error::from(std::io::ErrorKind::Unsupported))
452}
453
454/// Sums the values that are present; `None` if none are.
455fn sum_present(values: impl IntoIterator<Item = Option<f32>>) -> Option<f32> {
456    values
457        .into_iter()
458        .flatten()
459        .fold(None, |acc, v| Some(acc.unwrap_or(0.0) + v))
460}
461
462/// Finds the composite temperature of the NVMe controller backing `disk`
463/// (hwmon chip key `nvme:nvme0` for disk `nvme0n1`).
464#[cfg(target_os = "linux")]
465fn nvme_temperature(chips: &[linux::hwmon::HwmonChip], disk: &str) -> Option<f32> {
466    chips.iter().find_map(|c| {
467        let controller = c.chip.key.strip_prefix("nvme:")?;
468        device_matches_disk(controller, disk).then_some(())?;
469        // The first channel is the "Composite" temperature the drive reports.
470        c.chip.temperatures.first().map(|t| t.celsius)
471    })
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477    use crate::snapshot::ProcessInfo;
478
479    #[test]
480    fn named_processes_are_reported_until_seen_gone() {
481        let process = |pid, name: &str, command: &str| ProcessInfo {
482            pid,
483            name: name.to_owned(),
484            command: command.to_owned(),
485            ..Default::default()
486        };
487        let names = vec!["train.py".to_owned()];
488        let mut snapshot = Snapshot {
489            processes: vec![
490                process(10, "python3", "python3 jobs/train.py --epochs 3"),
491                process(11, "vim", "vim notes.txt"),
492            ],
493            watched_pids: vec![99],
494            ..Default::default()
495        };
496        let matches = report_name_matches(&names, &[], &mut snapshot);
497        assert_eq!(matches, [10]);
498        assert_eq!(snapshot.watched_pids, [99, 10]);
499
500        // Gone: reported once more, then forgotten.
501        let mut later = Snapshot {
502            processes: vec![process(11, "vim", "vim notes.txt")],
503            ..Default::default()
504        };
505        let matches = report_name_matches(&names, &matches, &mut later);
506        assert!(matches.is_empty());
507        assert_eq!(later.watched_pids, [10]);
508        let mut after = Snapshot::default();
509        assert!(report_name_matches(&names, &matches, &mut after).is_empty());
510        assert!(after.watched_pids.is_empty());
511    }
512
513    #[cfg(unix)]
514    #[test]
515    fn signal_rejects_group_pids_and_reports_missing_process() {
516        assert_eq!(
517            send_signal(0, ProcessSignal::Terminate).unwrap_err().kind(),
518            std::io::ErrorKind::InvalidInput
519        );
520        assert_eq!(
521            send_signal(u32::MAX, ProcessSignal::Kill)
522                .unwrap_err()
523                .kind(),
524            std::io::ErrorKind::InvalidInput
525        );
526        // A child that has been reaped no longer exists: ESRCH.
527        let mut child = std::process::Command::new("true").spawn().unwrap();
528        let pid = child.id();
529        child.wait().unwrap();
530        let err = send_signal(pid, ProcessSignal::Terminate).unwrap_err();
531        assert_eq!(err.raw_os_error(), Some(libc::ESRCH));
532    }
533
534    #[cfg(unix)]
535    #[test]
536    fn signal_terminates_a_child_process() {
537        let mut child = std::process::Command::new("sleep")
538            .arg("30")
539            .spawn()
540            .unwrap();
541        send_signal(child.id(), ProcessSignal::Kill).unwrap();
542        let status = child.wait().unwrap();
543        assert!(!status.success());
544    }
545
546    #[test]
547    fn sum_present_ignores_missing_values() {
548        assert_eq!(sum_present([None, None]), None);
549        assert_eq!(sum_present([Some(1.5), None, Some(2.0)]), Some(3.5));
550    }
551
552    #[cfg(target_os = "linux")]
553    #[test]
554    fn nvme_temperature_matches_controller_exactly() {
555        use crate::linux::hwmon::HwmonChip;
556        use crate::snapshot::{SensorChip, Temperature};
557
558        let chip = |key: &str, celsius: f32| HwmonChip {
559            chip: SensorChip {
560                key: key.to_owned(),
561                name: "nvme".to_owned(),
562                temperatures: vec![Temperature {
563                    label: "Composite".to_owned(),
564                    celsius,
565                    ..Default::default()
566                }],
567                fans: Vec::new(),
568            },
569            power_w: Vec::new(),
570            is_apu: false,
571        };
572        let chips = vec![chip("nvme:nvme1", 40.0), chip("nvme:nvme10", 50.0)];
573        assert_eq!(nvme_temperature(&chips, "nvme10n1"), Some(50.0));
574        assert_eq!(nvme_temperature(&chips, "nvme1n1"), Some(40.0));
575        assert_eq!(nvme_temperature(&chips, "sda"), None);
576    }
577}