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}
57
58pub trait DataSource {
60 fn poll(&mut self) -> Result<Snapshot, SourceError>;
66
67 fn signal_process(&mut self, pid: u32, signal: ProcessSignal) -> Result<(), SourceError>;
70
71 fn set_interval(&mut self, _interval: Duration) {}
75
76 fn set_process_limit(&mut self, _limit: Option<usize>) {}
79
80 fn watch_processes(&mut self, _pids: Vec<u32>) {}
84
85 fn watch_process_names(&mut self, _names: Vec<String>) {}
90}
91
92impl<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
119pub struct LocalSource {
121 system: SystemCollector,
122 nvml: NvmlCollector,
123 smart: SmartReader,
124 last_poll: Option<Instant>,
125 watched_pids: Vec<u32>,
127 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 #[cfg(target_os = "linux")]
149 core_locations: Vec<Option<linux::hwmon::CoreLocation>>,
150 #[cfg(target_os = "linux")]
152 scrub_dirs: Vec<std::path::PathBuf>,
153 #[cfg(target_os = "linux")]
155 drm_gpus: linux::drm::DrmGpuReader,
156 #[cfg(windows)]
158 lhm: crate::lhm::LhmReader,
159}
160
161impl LocalSource {
162 pub fn new() -> Self {
164 Self::with_config(&Config::default())
165 }
166
167 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 #[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 let _ = source.collect();
213 source
214 }
215
216 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 #[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 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 #[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 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 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 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 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
397fn 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#[cfg(unix)]
426fn send_signal(pid: u32, signal: ProcessSignal) -> std::io::Result<()> {
427 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 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#[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
454fn 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#[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 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 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 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}