1use 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
23pub const MIN_POLL_INTERVAL: Duration = sysinfo::MINIMUM_CPU_UPDATE_INTERVAL;
26
27#[derive(Debug, thiserror::Error)]
29pub enum SourceError {
30 #[error("I/O error: {0}")]
32 Io(#[from] std::io::Error),
33 #[error("source disconnected: {0}")]
35 Disconnected(String),
36 #[error("remote: {0}")]
38 Remote(String),
39 #[error("cannot signal process {pid}: {source}")]
41 Signal {
42 pid: u32,
44 source: std::io::Error,
46 },
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
51pub enum ProcessSignal {
52 Terminate,
54 Kill,
56 Stop,
59 Continue,
61 Nice(i32),
64}
65
66impl ProcessSignal {
67 pub fn is_control(self) -> bool {
69 matches!(self, Self::Stop | Self::Continue | Self::Nice(_))
70 }
71}
72
73pub trait DataSource {
75 fn poll(&mut self) -> Result<Snapshot, SourceError>;
81
82 fn signal_process(&mut self, pid: u32, signal: ProcessSignal) -> Result<(), SourceError>;
85
86 fn set_interval(&mut self, _interval: Duration) {}
90
91 fn set_process_limit(&mut self, _limit: Option<usize>) {}
94
95 fn watch_processes(&mut self, _pids: Vec<u32>) {}
99
100 fn watch_process_names(&mut self, _names: Vec<String>) {}
105}
106
107impl<T: DataSource + ?Sized> DataSource for Box<T> {
109 fn poll(&mut self) -> Result<Snapshot, SourceError> {
110 (**self).poll()
111 }
112
113 fn signal_process(&mut self, pid: u32, signal: ProcessSignal) -> Result<(), SourceError> {
114 (**self).signal_process(pid, signal)
115 }
116
117 fn set_interval(&mut self, interval: Duration) {
118 (**self).set_interval(interval);
119 }
120
121 fn set_process_limit(&mut self, limit: Option<usize>) {
122 (**self).set_process_limit(limit);
123 }
124
125 fn watch_processes(&mut self, pids: Vec<u32>) {
126 (**self).watch_processes(pids);
127 }
128
129 fn watch_process_names(&mut self, names: Vec<String>) {
130 (**self).watch_process_names(names);
131 }
132}
133
134pub struct LocalSource {
136 system: SystemCollector,
137 nvml: NvmlCollector,
138 smart: SmartReader,
139 last_poll: Option<Instant>,
140 watched_pids: Vec<u32>,
142 watched_names: Vec<String>,
145 name_matches: Vec<u32>,
146 fill: crate::fill::FillTracker,
147 #[cfg(target_os = "linux")]
148 roots: SysRoots,
149 #[cfg(target_os = "linux")]
150 rapl: RaplMeter,
151 #[cfg(target_os = "linux")]
152 diskstats: DiskStatsMeter,
153 #[cfg(target_os = "linux")]
154 net_links: NetLinkReader,
155 #[cfg(target_os = "linux")]
156 failed_units: linux::systemd::FailedUnitsReader,
157 #[cfg(target_os = "linux")]
158 containers: linux::containers::ContainerTracker,
159 #[cfg(target_os = "linux")]
160 oom: linux::oom::OomTracker,
161 #[cfg(target_os = "linux")]
162 battery: linux::battery::BatteryMeter,
163 #[cfg(target_os = "linux")]
166 core_locations: Vec<Option<linux::hwmon::CoreLocation>>,
167 #[cfg(target_os = "linux")]
169 scrub_dirs: Vec<std::path::PathBuf>,
170 #[cfg(target_os = "linux")]
172 drm_gpus: linux::drm::DrmGpuReader,
173 #[cfg(windows)]
175 lhm: crate::lhm::LhmReader,
176}
177
178impl LocalSource {
179 pub fn new() -> Self {
181 Self::with_config(&Config::default())
182 }
183
184 pub fn with_config(config: &Config) -> Self {
191 let mut source = Self {
192 system: SystemCollector::new(),
193 nvml: NvmlCollector::new(),
194 smart: SmartReader::new(&config.smart.dir),
195 last_poll: None,
196 watched_pids: Vec::new(),
197 watched_names: Vec::new(),
198 name_matches: Vec::new(),
199 fill: crate::fill::FillTracker::new(),
200 #[cfg(target_os = "linux")]
201 roots: SysRoots::default(),
202 #[cfg(target_os = "linux")]
203 rapl: RaplMeter::new(),
204 #[cfg(target_os = "linux")]
205 diskstats: DiskStatsMeter::new(),
206 #[cfg(target_os = "linux")]
207 net_links: NetLinkReader::new(),
208 #[cfg(target_os = "linux")]
209 failed_units: linux::systemd::FailedUnitsReader::new(),
210 #[cfg(target_os = "linux")]
211 containers: linux::containers::ContainerTracker::new(),
212 #[cfg(target_os = "linux")]
213 oom: linux::oom::OomTracker::new(),
214 #[cfg(target_os = "linux")]
215 battery: linux::battery::BatteryMeter::default(),
216 #[cfg(target_os = "linux")]
217 core_locations: Vec::new(),
218 #[cfg(target_os = "linux")]
219 drm_gpus: linux::drm::DrmGpuReader::new(),
220 #[cfg(windows)]
223 lhm: crate::lhm::LhmReader::new(&config.lhm.url),
224 #[cfg(target_os = "linux")]
225 scrub_dirs: vec![
226 config.btrfs.scrub_dir.clone(),
227 linux::btrfs::BTRFS_PROGS_STATUS_DIR.into(),
228 ],
229 };
230 let _ = source.collect();
232 source
233 }
234
235 pub fn gpu_unavailable_reason(&self) -> Option<&str> {
237 self.nvml.init_error()
238 }
239
240 fn collect(&mut self) -> Snapshot {
241 let now = Instant::now();
242 let elapsed = self
243 .last_poll
244 .map(|prev| now.saturating_duration_since(prev).as_secs_f64());
245 self.last_poll = Some(now);
246
247 self.system.refresh();
248 let processes = self.system.processes(elapsed);
249 let gpus = self.nvml.collect();
250 let gpus_w = sum_present(gpus.iter().map(|g| g.power_w));
251
252 let mut snapshot = Snapshot {
253 timestamp_ms: SystemTime::now()
254 .duration_since(UNIX_EPOCH)
255 .map(|d| d.as_millis() as u64)
256 .unwrap_or(0),
257 host: self.system.host(),
258 cpu: self.system.cpu(),
259 memory: self.system.memory(),
260 networks: self.system.networks(elapsed),
261 filesystems: self.system.filesystems(),
262 process_count: processes.len(),
263 processes,
264 watched_pids: self.watched_pids.clone(),
265 gpus,
266 smart: self.smart.read(),
267 power: PowerStats {
268 gpus_w,
269 ..Default::default()
270 },
271 ..Default::default()
272 };
273
274 #[cfg(target_os = "linux")]
275 self.collect_linux(&mut snapshot, now);
276 #[cfg(not(target_os = "linux"))]
277 self.collect_portable(&mut snapshot, elapsed);
278
279 self.fill.annotate(&mut snapshot.filesystems, now);
280 if !(self.watched_names.is_empty() && self.name_matches.is_empty()) {
281 self.name_matches =
282 report_name_matches(&self.watched_names, &self.name_matches, &mut snapshot);
283 }
284 let power = &mut snapshot.power;
285 power.total_w = sum_present([power.cpu_or_soc_w().1, power.gpus_w]);
286 snapshot
287 }
288
289 #[cfg(not(target_os = "linux"))]
292 fn collect_portable(&mut self, snapshot: &mut Snapshot, elapsed: Option<f64>) {
293 snapshot.disks = self.system.volume_io(elapsed);
294 let (chips, cpu_temperatures) = self.system.components();
295 snapshot.sensors = chips;
296 snapshot.cpu.temperatures = cpu_temperatures;
297 #[cfg(windows)]
298 {
299 crate::windows::annotate_links(&mut snapshot.networks);
300 match self.lhm.read(Instant::now()) {
303 Some(lhm) => {
304 if !lhm.chips.is_empty() {
305 snapshot.sensors = lhm.chips;
306 }
307 if !lhm.cpu_temperatures.is_empty() {
308 snapshot.cpu.temperatures = lhm.cpu_temperatures;
309 }
310 snapshot.power.cpu_package_w = lhm.cpu_package_w;
311 if !lhm.smart.is_empty() {
312 snapshot.smart = lhm.smart;
313 }
314 }
315 None => snapshot.lhm_error = self.lhm.problem().map(str::to_owned),
316 }
317 }
318 }
319
320 #[cfg(target_os = "linux")]
322 fn collect_linux(&mut self, snapshot: &mut Snapshot, now: Instant) {
323 let chips = linux::hwmon::read_chips(&self.roots);
324
325 let cores = snapshot.cpu.per_core_percent.len();
326 if self.core_locations.len() != cores {
327 self.core_locations = linux::hwmon::read_core_locations(&self.roots, cores);
328 }
329 (snapshot.cpu.temperatures, snapshot.cpu.per_core_celsius) =
330 linux::hwmon::split_core_temperatures(&chips, &self.core_locations);
331 snapshot.memory.cached_bytes = linux::meminfo::read_cached_bytes(&self.roots);
332 snapshot.memory.oom_kills =
333 self.oom
334 .sample(&self.roots, &snapshot.processes, snapshot.timestamp_ms);
335 snapshot.power.cpu_package_w = self.rapl.sample(&self.roots, now);
336 snapshot.power.igpu_w = sum_present(
339 chips
340 .iter()
341 .filter(|c| c.is_apu)
342 .flat_map(|c| c.power_w.iter().map(|(_, w)| Some(*w))),
343 );
344 snapshot.power.battery = self.battery.read(&self.roots);
345
346 let first = snapshot.gpus.len() as u32;
348 snapshot.gpus.extend(self.drm_gpus.read(&self.roots, first));
349 snapshot.power.gpus_w = sum_present(snapshot.gpus.iter().map(|g| g.power_w));
350
351 let mut disks = self.diskstats.sample(&self.roots, now);
352 for disk in &mut disks {
353 disk.temperature_c = nvme_temperature(&chips, &disk.name);
354 }
355 snapshot.disks = disks;
356
357 let mounts = linux::mounts::read_mounts(&self.roots);
358 snapshot.raid.btrfs = linux::btrfs::read_filesystems(&self.roots, &mounts);
359 for fs in &mut snapshot.raid.btrfs {
360 fs.scrub = linux::btrfs::read_scrub(&self.scrub_dirs, &fs.fsid);
361 }
362 self.net_links
363 .annotate(&self.roots, &mut snapshot.networks, now);
364 snapshot.raid.md = linux::mdstat::read_arrays(&self.roots);
365 snapshot.raid.zfs = linux::zfs::read_pools(&self.roots);
366 snapshot.failed_units = self.failed_units.read(now);
367 snapshot.pressure = linux::pressure::read_pressure(&self.roots);
368 let release = std::fs::read_to_string(self.roots.proc.join("sys/kernel/osrelease"))
369 .unwrap_or_default();
370 snapshot.host.reboot_required = linux::reboot::reboot_required(
371 std::path::Path::new("/run"),
372 std::path::Path::new("/usr/lib/modules"),
373 &release,
374 );
375 for process in &mut snapshot.processes {
377 if let Some(stat) = linux::procstat::read(&self.roots, process.pid) {
378 process.threads = stat.threads;
379 process.nice = stat.nice;
380 } else {
381 process.threads = None;
382 }
383 }
384 self.containers
385 .annotate(&self.roots, &mut snapshot.processes, now);
386
387 snapshot.sensors = chips.into_iter().map(|c| c.chip).collect();
388 }
389}
390
391impl Default for LocalSource {
392 fn default() -> Self {
393 Self::new()
394 }
395}
396
397impl DataSource for LocalSource {
398 fn poll(&mut self) -> Result<Snapshot, SourceError> {
399 Ok(self.collect())
400 }
401
402 fn signal_process(&mut self, pid: u32, signal: ProcessSignal) -> Result<(), SourceError> {
403 send_signal(pid, signal).map_err(|source| SourceError::Signal { pid, source })
404 }
405
406 fn set_process_limit(&mut self, limit: Option<usize>) {
409 self.nvml.set_process_utilization(limit.is_none());
410 }
411
412 fn watch_processes(&mut self, pids: Vec<u32>) {
413 self.watched_pids = pids;
414 }
415
416 fn watch_process_names(&mut self, names: Vec<String>) {
417 self.watched_names = names;
418 }
419}
420
421fn report_name_matches(names: &[String], previous: &[u32], snapshot: &mut Snapshot) -> Vec<u32> {
426 let matches: Vec<u32> = snapshot
427 .processes
428 .iter()
429 .filter(|p| {
430 !p.kernel_thread
431 && names
432 .iter()
433 .any(|name| crate::view::process_matches(p, name))
434 })
435 .map(|p| p.pid)
436 .collect();
437 let gone = previous
438 .iter()
439 .filter(|pid| !snapshot.processes.iter().any(|p| p.pid == **pid));
440 for pid in matches.iter().chain(gone) {
441 if !snapshot.watched_pids.contains(pid) {
442 snapshot.watched_pids.push(*pid);
443 }
444 }
445 matches
446}
447
448#[cfg(unix)]
450fn send_signal(pid: u32, signal: ProcessSignal) -> std::io::Result<()> {
451 let pid = libc::pid_t::try_from(pid)
454 .ok()
455 .filter(|p| *p > 0)
456 .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
457 let sig = match signal {
458 ProcessSignal::Terminate => libc::SIGTERM,
459 ProcessSignal::Kill => libc::SIGKILL,
460 ProcessSignal::Stop => libc::SIGSTOP,
461 ProcessSignal::Continue => libc::SIGCONT,
462 ProcessSignal::Nice(nice) => return set_nice(pid, nice),
463 };
464 if unsafe { libc::kill(pid, sig) } == 0 {
466 Ok(())
467 } else {
468 Err(std::io::Error::last_os_error())
469 }
470}
471
472#[cfg(unix)]
474fn set_nice(pid: libc::pid_t, nice: i32) -> std::io::Result<()> {
475 let nice = nice.clamp(-20, 19);
476 if unsafe { libc::setpriority(libc::PRIO_PROCESS as _, pid as _, nice) } == 0 {
479 Ok(())
480 } else {
481 Err(std::io::Error::last_os_error())
482 }
483}
484
485#[cfg(windows)]
486use crate::windows::send_signal;
487
488#[cfg(not(any(unix, windows)))]
490fn send_signal(_pid: u32, _signal: ProcessSignal) -> std::io::Result<()> {
491 Err(std::io::Error::from(std::io::ErrorKind::Unsupported))
492}
493
494fn sum_present(values: impl IntoIterator<Item = Option<f32>>) -> Option<f32> {
496 values
497 .into_iter()
498 .flatten()
499 .fold(None, |acc, v| Some(acc.unwrap_or(0.0) + v))
500}
501
502#[cfg(target_os = "linux")]
505fn nvme_temperature(chips: &[linux::hwmon::HwmonChip], disk: &str) -> Option<f32> {
506 chips.iter().find_map(|c| {
507 let controller = c.chip.key.strip_prefix("nvme:")?;
508 device_matches_disk(controller, disk).then_some(())?;
509 c.chip.temperatures.first().map(|t| t.celsius)
511 })
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517 use crate::snapshot::ProcessInfo;
518
519 #[test]
520 fn named_processes_are_reported_until_seen_gone() {
521 let process = |pid, name: &str, command: &str| ProcessInfo {
522 pid,
523 name: name.to_owned(),
524 command: command.to_owned(),
525 ..Default::default()
526 };
527 let names = vec!["train.py".to_owned()];
528 let mut snapshot = Snapshot {
529 processes: vec![
530 process(10, "python3", "python3 jobs/train.py --epochs 3"),
531 process(11, "vim", "vim notes.txt"),
532 ],
533 watched_pids: vec![99],
534 ..Default::default()
535 };
536 let matches = report_name_matches(&names, &[], &mut snapshot);
537 assert_eq!(matches, [10]);
538 assert_eq!(snapshot.watched_pids, [99, 10]);
539
540 let mut later = Snapshot {
542 processes: vec![process(11, "vim", "vim notes.txt")],
543 ..Default::default()
544 };
545 let matches = report_name_matches(&names, &matches, &mut later);
546 assert!(matches.is_empty());
547 assert_eq!(later.watched_pids, [10]);
548 let mut after = Snapshot::default();
549 assert!(report_name_matches(&names, &matches, &mut after).is_empty());
550 assert!(after.watched_pids.is_empty());
551 }
552
553 #[cfg(unix)]
554 #[test]
555 fn signal_rejects_group_pids_and_reports_missing_process() {
556 assert_eq!(
557 send_signal(0, ProcessSignal::Terminate).unwrap_err().kind(),
558 std::io::ErrorKind::InvalidInput
559 );
560 assert_eq!(
561 send_signal(u32::MAX, ProcessSignal::Kill)
562 .unwrap_err()
563 .kind(),
564 std::io::ErrorKind::InvalidInput
565 );
566 let mut child = std::process::Command::new("true").spawn().unwrap();
568 let pid = child.id();
569 child.wait().unwrap();
570 let err = send_signal(pid, ProcessSignal::Terminate).unwrap_err();
571 assert_eq!(err.raw_os_error(), Some(libc::ESRCH));
572 }
573
574 #[cfg(unix)]
575 #[test]
576 fn signal_terminates_a_child_process() {
577 let mut child = std::process::Command::new("sleep")
578 .arg("30")
579 .spawn()
580 .unwrap();
581 send_signal(child.id(), ProcessSignal::Kill).unwrap();
582 let status = child.wait().unwrap();
583 assert!(!status.success());
584 }
585
586 #[cfg(target_os = "linux")]
587 #[test]
588 fn pause_resume_and_renice_a_child_process() {
589 let mut child = std::process::Command::new("sleep")
590 .arg("30")
591 .spawn()
592 .unwrap();
593 let pid = child.id();
594 let roots = SysRoots::default();
595 let state = || {
596 let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).unwrap();
597 let after = &stat[stat.rfind(')').unwrap() + 2..];
598 after.chars().next().unwrap()
599 };
600 send_signal(pid, ProcessSignal::Stop).unwrap();
601 for _ in 0..100 {
603 if state() == 'T' {
604 break;
605 }
606 std::thread::sleep(std::time::Duration::from_millis(10));
607 }
608 assert_eq!(state(), 'T');
609 send_signal(pid, ProcessSignal::Continue).unwrap();
610 send_signal(pid, ProcessSignal::Nice(7)).unwrap();
612 assert_eq!(linux::procstat::read(&roots, pid).unwrap().nice, Some(7));
613 child.kill().unwrap();
614 child.wait().unwrap();
615 }
616
617 #[test]
618 fn sum_present_ignores_missing_values() {
619 assert_eq!(sum_present([None, None]), None);
620 assert_eq!(sum_present([Some(1.5), None, Some(2.0)]), Some(3.5));
621 }
622
623 #[cfg(target_os = "linux")]
624 #[test]
625 fn nvme_temperature_matches_controller_exactly() {
626 use crate::linux::hwmon::HwmonChip;
627 use crate::snapshot::{SensorChip, Temperature};
628
629 let chip = |key: &str, celsius: f32| HwmonChip {
630 chip: SensorChip {
631 key: key.to_owned(),
632 name: "nvme".to_owned(),
633 temperatures: vec![Temperature {
634 label: "Composite".to_owned(),
635 celsius,
636 ..Default::default()
637 }],
638 fans: Vec::new(),
639 },
640 power_w: Vec::new(),
641 is_apu: false,
642 };
643 let chips = vec![chip("nvme:nvme1", 40.0), chip("nvme:nvme10", 50.0)];
644 assert_eq!(nvme_temperature(&chips, "nvme10n1"), Some(50.0));
645 assert_eq!(nvme_temperature(&chips, "nvme1n1"), Some(40.0));
646 assert_eq!(nvme_temperature(&chips, "sda"), None);
647 }
648}