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
81/// Boxed sources, so front-ends can choose local or remote at run time.
82impl<T: DataSource + ?Sized> DataSource for Box<T> {
83    fn poll(&mut self) -> Result<Snapshot, SourceError> {
84        (**self).poll()
85    }
86
87    fn signal_process(&mut self, pid: u32, signal: ProcessSignal) -> Result<(), SourceError> {
88        (**self).signal_process(pid, signal)
89    }
90
91    fn set_interval(&mut self, interval: Duration) {
92        (**self).set_interval(interval);
93    }
94
95    fn set_process_limit(&mut self, limit: Option<usize>) {
96        (**self).set_process_limit(limit);
97    }
98}
99
100/// Collects snapshots of the machine rigtop runs on.
101pub struct LocalSource {
102    system: SystemCollector,
103    nvml: NvmlCollector,
104    smart: SmartReader,
105    last_poll: Option<Instant>,
106    #[cfg(target_os = "linux")]
107    roots: SysRoots,
108    #[cfg(target_os = "linux")]
109    rapl: RaplMeter,
110    #[cfg(target_os = "linux")]
111    diskstats: DiskStatsMeter,
112    #[cfg(target_os = "linux")]
113    net_links: NetLinkReader,
114    #[cfg(target_os = "linux")]
115    failed_units: linux::systemd::FailedUnitsReader,
116    #[cfg(target_os = "linux")]
117    containers: linux::containers::ContainerTracker,
118    /// Directories searched for btrfs scrub status files, in order.
119    #[cfg(target_os = "linux")]
120    scrub_dirs: Vec<std::path::PathBuf>,
121    /// AMD and Intel GPUs (NVIDIA ones come from NVML).
122    #[cfg(target_os = "linux")]
123    drm_gpus: linux::drm::DrmGpuReader,
124    /// Temperatures, fans and CPU power from LibreHardwareMonitor.
125    #[cfg(windows)]
126    lhm: crate::lhm::LhmReader,
127}
128
129impl LocalSource {
130    /// Creates a source with the default configuration. See [`LocalSource::with_config`].
131    pub fn new() -> Self {
132        Self::with_config(&Config::default())
133    }
134
135    /// Creates a source and takes a warm-up sample, so that the first
136    /// [`DataSource::poll`] (after at least [`MIN_POLL_INTERVAL`]) already
137    /// has rates.
138    ///
139    /// Only collection-related settings (the SMART directory) are used here;
140    /// display settings such as sensor labels are applied by front-ends.
141    pub fn with_config(config: &Config) -> Self {
142        let mut source = Self {
143            system: SystemCollector::new(),
144            nvml: NvmlCollector::new(),
145            smart: SmartReader::new(&config.smart.dir),
146            last_poll: None,
147            #[cfg(target_os = "linux")]
148            roots: SysRoots::default(),
149            #[cfg(target_os = "linux")]
150            rapl: RaplMeter::new(),
151            #[cfg(target_os = "linux")]
152            diskstats: DiskStatsMeter::new(),
153            #[cfg(target_os = "linux")]
154            net_links: NetLinkReader::new(),
155            #[cfg(target_os = "linux")]
156            failed_units: linux::systemd::FailedUnitsReader::new(),
157            #[cfg(target_os = "linux")]
158            containers: linux::containers::ContainerTracker::new(),
159            #[cfg(target_os = "linux")]
160            drm_gpus: linux::drm::DrmGpuReader::new(),
161            // The copy made by the root collector first, then btrfs-progs'
162            // own file in case rigtop runs as root.
163            #[cfg(windows)]
164            lhm: crate::lhm::LhmReader::new(&config.lhm.url),
165            #[cfg(target_os = "linux")]
166            scrub_dirs: vec![
167                config.btrfs.scrub_dir.clone(),
168                linux::btrfs::BTRFS_PROGS_STATUS_DIR.into(),
169            ],
170        };
171        // The warm-up result itself is meaningless (no previous sample).
172        let _ = source.collect();
173        source
174    }
175
176    /// Why NVIDIA GPUs are unavailable, if NVML failed to initialize.
177    pub fn gpu_unavailable_reason(&self) -> Option<&str> {
178        self.nvml.init_error()
179    }
180
181    fn collect(&mut self) -> Snapshot {
182        let now = Instant::now();
183        let elapsed = self
184            .last_poll
185            .map(|prev| now.saturating_duration_since(prev).as_secs_f64());
186        self.last_poll = Some(now);
187
188        self.system.refresh();
189        let processes = self.system.processes(elapsed);
190        let gpus = self.nvml.collect();
191        let gpus_w = sum_present(gpus.iter().map(|g| g.power_w));
192
193        let mut snapshot = Snapshot {
194            timestamp_ms: SystemTime::now()
195                .duration_since(UNIX_EPOCH)
196                .map(|d| d.as_millis() as u64)
197                .unwrap_or(0),
198            host: self.system.host(),
199            cpu: self.system.cpu(),
200            memory: self.system.memory(),
201            networks: self.system.networks(elapsed),
202            filesystems: self.system.filesystems(),
203            process_count: processes.len(),
204            processes,
205            gpus,
206            smart: self.smart.read(),
207            power: PowerStats {
208                gpus_w,
209                ..Default::default()
210            },
211            ..Default::default()
212        };
213
214        #[cfg(target_os = "linux")]
215        self.collect_linux(&mut snapshot, now);
216        #[cfg(not(target_os = "linux"))]
217        self.collect_portable(&mut snapshot, elapsed);
218
219        let power = &mut snapshot.power;
220        power.total_w = sum_present([power.cpu_or_soc_w().1, power.gpus_w]);
221        snapshot
222    }
223
224    /// Adds what `sysinfo` offers beyond the basics on non-Linux platforms:
225    /// per-volume I/O, temperatures and (on Windows) link speeds.
226    #[cfg(not(target_os = "linux"))]
227    fn collect_portable(&mut self, snapshot: &mut Snapshot, elapsed: Option<f64>) {
228        snapshot.disks = self.system.volume_io(elapsed);
229        let (chips, cpu_temperatures) = self.system.components();
230        snapshot.sensors = chips;
231        snapshot.cpu.temperatures = cpu_temperatures;
232        #[cfg(windows)]
233        {
234            crate::windows::annotate_links(&mut snapshot.networks);
235            // LibreHardwareMonitor, when running, knows far more than the
236            // ACPI thermal zones sysinfo can see.
237            match self.lhm.read(Instant::now()) {
238                Some(lhm) => {
239                    if !lhm.chips.is_empty() {
240                        snapshot.sensors = lhm.chips;
241                    }
242                    if !lhm.cpu_temperatures.is_empty() {
243                        snapshot.cpu.temperatures = lhm.cpu_temperatures;
244                    }
245                    snapshot.power.cpu_package_w = lhm.cpu_package_w;
246                    if !lhm.smart.is_empty() {
247                        snapshot.smart = lhm.smart;
248                    }
249                }
250                None => snapshot.lhm_error = self.lhm.problem().map(str::to_owned),
251            }
252        }
253    }
254
255    /// Adds sensors, power, per-device I/O and RAID state from `/sys` and `/proc`.
256    #[cfg(target_os = "linux")]
257    fn collect_linux(&mut self, snapshot: &mut Snapshot, now: Instant) {
258        let chips = linux::hwmon::read_chips(&self.roots);
259
260        snapshot.cpu.temperatures = chips
261            .iter()
262            .filter(|c| linux::hwmon::is_cpu_chip(&c.chip))
263            .flat_map(|c| c.chip.temperatures.iter().cloned())
264            .collect();
265        snapshot.memory.cached_bytes = linux::meminfo::read_cached_bytes(&self.roots);
266        snapshot.power.cpu_package_w = self.rapl.sample(&self.roots, now);
267        // The APU's amdgpu power covers the whole SoC (see PowerStats).
268        // Discrete AMD GPUs are not reported yet.
269        snapshot.power.igpu_w = sum_present(
270            chips
271                .iter()
272                .filter(|c| c.is_apu)
273                .flat_map(|c| c.power_w.iter().map(|(_, w)| Some(*w))),
274        );
275        snapshot.power.battery = linux::battery::read_battery(&self.roots);
276
277        // AMD and Intel GPUs after NVIDIA's; discrete ones add to GPU power.
278        let first = snapshot.gpus.len() as u32;
279        snapshot.gpus.extend(self.drm_gpus.read(&self.roots, first));
280        snapshot.power.gpus_w = sum_present(snapshot.gpus.iter().map(|g| g.power_w));
281
282        let mut disks = self.diskstats.sample(&self.roots, now);
283        for disk in &mut disks {
284            disk.temperature_c = nvme_temperature(&chips, &disk.name);
285        }
286        snapshot.disks = disks;
287
288        let mounts = linux::mounts::read_mounts(&self.roots);
289        snapshot.raid.btrfs = linux::btrfs::read_filesystems(&self.roots, &mounts);
290        for fs in &mut snapshot.raid.btrfs {
291            fs.scrub = linux::btrfs::read_scrub(&self.scrub_dirs, &fs.fsid);
292        }
293        self.net_links
294            .annotate(&self.roots, &mut snapshot.networks, now);
295        snapshot.raid.md = linux::mdstat::read_arrays(&self.roots);
296        snapshot.raid.zfs = linux::zfs::read_pools(&self.roots);
297        snapshot.failed_units = self.failed_units.read(now);
298        // sysinfo no longer lists threads (see SystemCollector::refresh).
299        for process in &mut snapshot.processes {
300            process.threads = linux::procstat::thread_count(&self.roots, process.pid);
301        }
302        self.containers
303            .annotate(&self.roots, &mut snapshot.processes, now);
304
305        snapshot.sensors = chips.into_iter().map(|c| c.chip).collect();
306    }
307}
308
309impl Default for LocalSource {
310    fn default() -> Self {
311        Self::new()
312    }
313}
314
315impl DataSource for LocalSource {
316    fn poll(&mut self) -> Result<Snapshot, SourceError> {
317        Ok(self.collect())
318    }
319
320    fn signal_process(&mut self, pid: u32, signal: ProcessSignal) -> Result<(), SourceError> {
321        send_signal(pid, signal).map_err(|source| SourceError::Signal { pid, source })
322    }
323
324    /// With a limit, only the top processes are shown (overview), which do
325    /// not include per-process GPU utilization: skip that NVML query.
326    fn set_process_limit(&mut self, limit: Option<usize>) {
327        self.nvml.set_process_utilization(limit.is_none());
328    }
329}
330
331/// Sends a signal to a local process.
332#[cfg(unix)]
333fn send_signal(pid: u32, signal: ProcessSignal) -> std::io::Result<()> {
334    // pid 0 and negative values address process groups in kill(2); never
335    // let a bad pid turn into "signal everything".
336    let pid = libc::pid_t::try_from(pid)
337        .ok()
338        .filter(|p| *p > 0)
339        .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
340    let sig = match signal {
341        ProcessSignal::Terminate => libc::SIGTERM,
342        ProcessSignal::Kill => libc::SIGKILL,
343    };
344    // SAFETY: kill(2) has no memory-safety preconditions.
345    if unsafe { libc::kill(pid, sig) } == 0 {
346        Ok(())
347    } else {
348        Err(std::io::Error::last_os_error())
349    }
350}
351
352#[cfg(windows)]
353use crate::windows::send_signal;
354
355/// Sends a signal to a local process (not implemented on this platform yet).
356#[cfg(not(any(unix, windows)))]
357fn send_signal(_pid: u32, _signal: ProcessSignal) -> std::io::Result<()> {
358    Err(std::io::Error::from(std::io::ErrorKind::Unsupported))
359}
360
361/// Sums the values that are present; `None` if none are.
362fn sum_present(values: impl IntoIterator<Item = Option<f32>>) -> Option<f32> {
363    values
364        .into_iter()
365        .flatten()
366        .fold(None, |acc, v| Some(acc.unwrap_or(0.0) + v))
367}
368
369/// Finds the composite temperature of the NVMe controller backing `disk`
370/// (hwmon chip key `nvme:nvme0` for disk `nvme0n1`).
371#[cfg(target_os = "linux")]
372fn nvme_temperature(chips: &[linux::hwmon::HwmonChip], disk: &str) -> Option<f32> {
373    chips.iter().find_map(|c| {
374        let controller = c.chip.key.strip_prefix("nvme:")?;
375        device_matches_disk(controller, disk).then_some(())?;
376        // The first channel is the "Composite" temperature the drive reports.
377        c.chip.temperatures.first().map(|t| t.celsius)
378    })
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[cfg(unix)]
386    #[test]
387    fn signal_rejects_group_pids_and_reports_missing_process() {
388        assert_eq!(
389            send_signal(0, ProcessSignal::Terminate).unwrap_err().kind(),
390            std::io::ErrorKind::InvalidInput
391        );
392        assert_eq!(
393            send_signal(u32::MAX, ProcessSignal::Kill)
394                .unwrap_err()
395                .kind(),
396            std::io::ErrorKind::InvalidInput
397        );
398        // A child that has been reaped no longer exists: ESRCH.
399        let mut child = std::process::Command::new("true").spawn().unwrap();
400        let pid = child.id();
401        child.wait().unwrap();
402        let err = send_signal(pid, ProcessSignal::Terminate).unwrap_err();
403        assert_eq!(err.raw_os_error(), Some(libc::ESRCH));
404    }
405
406    #[cfg(unix)]
407    #[test]
408    fn signal_terminates_a_child_process() {
409        let mut child = std::process::Command::new("sleep")
410            .arg("30")
411            .spawn()
412            .unwrap();
413        send_signal(child.id(), ProcessSignal::Kill).unwrap();
414        let status = child.wait().unwrap();
415        assert!(!status.success());
416    }
417
418    #[test]
419    fn sum_present_ignores_missing_values() {
420        assert_eq!(sum_present([None, None]), None);
421        assert_eq!(sum_present([Some(1.5), None, Some(2.0)]), Some(3.5));
422    }
423
424    #[cfg(target_os = "linux")]
425    #[test]
426    fn nvme_temperature_matches_controller_exactly() {
427        use crate::linux::hwmon::HwmonChip;
428        use crate::snapshot::{SensorChip, Temperature};
429
430        let chip = |key: &str, celsius: f32| HwmonChip {
431            chip: SensorChip {
432                key: key.to_owned(),
433                name: "nvme".to_owned(),
434                temperatures: vec![Temperature {
435                    label: "Composite".to_owned(),
436                    celsius,
437                    ..Default::default()
438                }],
439                fans: Vec::new(),
440            },
441            power_w: Vec::new(),
442            is_apu: false,
443        };
444        let chips = vec![chip("nvme:nvme1", 40.0), chip("nvme:nvme10", 50.0)];
445        assert_eq!(nvme_temperature(&chips, "nvme10n1"), Some(50.0));
446        assert_eq!(nvme_temperature(&chips, "nvme1n1"), Some(40.0));
447        assert_eq!(nvme_temperature(&chips, "sda"), None);
448    }
449}