use std::collections::BTreeSet;
use std::time::{Duration, Instant};
use sysinfo::{
CpuRefreshKind, DiskRefreshKind, Disks, IpNetwork, MemoryRefreshKind, NetworkData, Networks,
RefreshKind, System,
};
use crate::output::human_bytes;
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct HostSample {
pub cpu_percent: Option<f32>,
pub memory_used_bytes: u64,
pub memory_total_bytes: u64,
pub disk_bytes_per_second: Option<(u64, u64)>,
pub network_bytes_per_second: Option<(u64, u64)>,
}
impl HostSample {
pub(crate) fn line(&self) -> String {
let cpu = match self.cpu_percent {
Some(percent) => format!("{percent:.0}%"),
None => "-".to_owned(),
};
let (disk_read, disk_write) = rate_pair(self.disk_bytes_per_second);
let (received, transmitted) = rate_pair(self.network_bytes_per_second);
format!(
"host cpu {cpu} mem {}/{} disk r {disk_read} w {disk_write} net rx {received} tx {transmitted}",
human_bytes(self.memory_used_bytes),
human_bytes(self.memory_total_bytes),
)
}
}
fn rate_pair(rate: Option<(u64, u64)>) -> (String, String) {
match rate {
Some((first, second)) => (
format!("{}/s", human_bytes(first)),
format!("{}/s", human_bytes(second)),
),
None => ("-".to_owned(), "-".to_owned()),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct DiskIo {
pub lifetime: (u64, u64),
pub window: (u64, u64),
}
fn distinct_disk_io(entries: impl IntoIterator<Item = DiskIo>) -> (u64, u64) {
let mut seen = BTreeSet::new();
let mut read = 0u64;
let mut written = 0u64;
for entry in entries {
if seen.insert(entry.lifetime) {
read = read.saturating_add(entry.window.0);
written = written.saturating_add(entry.window.1);
}
}
(read, written)
}
fn is_loopback(interface: &NetworkData) -> bool {
addresses_are_loopback(interface.ip_networks())
}
fn addresses_are_loopback(addresses: &[IpNetwork]) -> bool {
!addresses.is_empty() && addresses.iter().all(|network| network.addr.is_loopback())
}
fn is_measurable(window: Duration) -> bool {
window >= sysinfo::MINIMUM_CPU_UPDATE_INTERVAL
}
fn per_second(bytes: u64, window: Duration) -> u64 {
let millis = window.as_millis().max(1);
u64::try_from(u128::from(bytes) * 1000 / millis).unwrap_or(u64::MAX)
}
#[derive(Debug)]
pub(crate) struct HostWatch {
system: System,
disks: Disks,
networks: Networks,
sampled_at: Instant,
}
impl HostWatch {
pub(crate) fn install() -> Option<Self> {
if !sysinfo::IS_SUPPORTED_SYSTEM {
return None;
}
Some(Self {
system: System::new_with_specifics(Self::refresh()),
disks: Disks::new_with_refreshed_list_specifics(Self::disk_refresh()),
networks: Networks::new_with_refreshed_list(),
sampled_at: Instant::now(),
})
}
fn refresh() -> RefreshKind {
RefreshKind::nothing()
.with_cpu(CpuRefreshKind::nothing().with_cpu_usage())
.with_memory(MemoryRefreshKind::nothing().with_ram())
}
fn disk_refresh() -> DiskRefreshKind {
DiskRefreshKind::nothing().with_io_usage()
}
pub(crate) fn sample(&mut self) -> HostSample {
let now = Instant::now();
let window = now.saturating_duration_since(self.sampled_at);
self.sampled_at = now;
self.system.refresh_specifics(Self::refresh());
self.disks.refresh_specifics(false, Self::disk_refresh());
self.networks.refresh(false);
let measured = is_measurable(window);
let disk = measured.then(|| {
let (read, written) = distinct_disk_io(self.disks.list().iter().map(|disk| {
let usage = disk.usage();
DiskIo {
lifetime: (usage.total_read_bytes, usage.total_written_bytes),
window: (usage.read_bytes, usage.written_bytes),
}
}));
(per_second(read, window), per_second(written, window))
});
let network = measured.then(|| {
let (received, transmitted) = self
.networks
.list()
.values()
.filter(|interface| !is_loopback(interface))
.fold((0u64, 0u64), |(received, transmitted), interface| {
(
received.saturating_add(interface.received()),
transmitted.saturating_add(interface.transmitted()),
)
});
(
per_second(received, window),
per_second(transmitted, window),
)
});
HostSample {
cpu_percent: measured.then(|| self.system.global_cpu_usage()),
memory_used_bytes: self.system.used_memory(),
memory_total_bytes: self.system.total_memory(),
disk_bytes_per_second: disk,
network_bytes_per_second: network,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn io(lifetime: (u64, u64), window: (u64, u64)) -> DiskIo {
DiskIo { lifetime, window }
}
#[test]
fn two_mount_points_on_one_device_are_counted_once() {
let volumes = [
io((1_089_321_177_088, 1_750_606_974_976), (2_641_920, 4_096)),
io((1_089_321_177_088, 1_750_606_974_976), (2_625_536, 4_096)),
];
assert_eq!(distinct_disk_io(volumes), (2_641_920, 4_096));
}
#[test]
fn two_devices_are_counted_twice() {
let devices = [io((100, 200), (10, 20)), io((300, 400), (30, 40))];
assert_eq!(distinct_disk_io(devices), (40, 60));
}
#[test]
fn no_devices_move_no_bytes() {
assert_eq!(distinct_disk_io([]), (0, 0));
}
#[test]
fn a_rate_scales_a_window_up_to_a_second() {
assert_eq!(
per_second(100 * 1024 * 1024, Duration::from_millis(100)),
1000 * 1024 * 1024
);
assert_eq!(per_second(u64::MAX, Duration::from_millis(1)), u64::MAX);
assert_eq!(per_second(512, Duration::from_secs(2)), 256);
}
#[test]
fn a_window_of_no_time_does_not_divide_by_zero() {
assert_eq!(per_second(4, Duration::ZERO), 4000);
}
#[test]
fn a_sample_with_no_window_dashes_every_rate_and_still_prints_memory() {
let sample = HostSample {
cpu_percent: None,
memory_used_bytes: 39_963_869_184,
memory_total_bytes: 51_539_607_552,
disk_bytes_per_second: None,
network_bytes_per_second: None,
};
assert_eq!(
sample.line(),
"host cpu - mem 37.2G/48.0G disk r - w - net rx - tx -"
);
}
#[test]
fn a_measured_sample_names_all_four() {
let sample = HostSample {
cpu_percent: Some(11.459_433),
memory_used_bytes: 39_963_869_184,
memory_total_bytes: 51_539_607_552,
disk_bytes_per_second: Some((1_258_291, 491_520)),
network_bytes_per_second: Some((24_594, 9_260)),
};
assert_eq!(
sample.line(),
"host cpu 11% mem 37.2G/48.0G disk r 1.2M/s w 480.0K/s net rx 24.0K/s tx 9.0K/s"
);
}
#[test]
fn a_window_under_sysinfos_own_floor_carries_no_rate() {
assert!(!is_measurable(Duration::ZERO));
assert!(!is_measurable(Duration::from_millis(5)));
assert!(!is_measurable(
sysinfo::MINIMUM_CPU_UPDATE_INTERVAL - Duration::from_nanos(1)
));
assert!(is_measurable(sysinfo::MINIMUM_CPU_UPDATE_INTERVAL));
assert!(is_measurable(Duration::from_secs(1)), "the interval floor");
}
#[test]
fn a_real_sample_reads_this_machines_memory() {
let Some(mut watch) = HostWatch::install() else {
return;
};
let sample = watch.sample();
assert!(sample.memory_total_bytes > 0, "a machine has memory");
assert!(sample.memory_used_bytes > 0);
}
fn net(addr: &str) -> IpNetwork {
IpNetwork {
addr: addr.parse().expect("a literal address"),
prefix: 8,
}
}
#[test]
fn an_interface_is_loopback_only_when_every_address_is() {
assert!(addresses_are_loopback(&[net("127.0.0.1")]));
assert!(addresses_are_loopback(&[net("127.0.0.1"), net("::1")]));
assert!(!addresses_are_loopback(&[net("192.168.1.4")]));
assert!(
!addresses_are_loopback(&[net("127.0.0.1"), net("192.168.1.4")]),
"one routable address is enough to make an interface count"
);
}
#[test]
fn an_interface_with_no_addresses_is_not_loopback() {
assert!(!addresses_are_loopback(&[]));
}
}