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