use std::collections::HashMap;
use std::fs;
use std::io::{self, Read};
use std::os::unix::fs::MetadataExt;
use std::path::Path;
use std::sync::Arc;
pub const CMDLINE_LIMIT: usize = 16 * 1024;
pub const PROC_STAT_BYTES_LIMIT: usize = 512 * 1024;
pub const PROC_MEMINFO_BYTES_LIMIT: usize = 64 * 1024;
pub const PROC_LOADAVG_BYTES_LIMIT: usize = 1024;
pub const PROC_UPTIME_BYTES_LIMIT: usize = 1024;
pub const PROC_PID_STAT_BYTES_LIMIT: usize = 4096;
pub const PASSWD_BYTES_LIMIT: usize = 1024 * 1024;
pub const USER_RETAIN_LIMIT: usize = 128;
pub const PROCESS_NAME_RETAIN_LIMIT: usize = 256;
pub const CMD_RETAIN_LIMIT: usize = 4096;
pub const SEARCH_TEXT_RETAIN_LIMIT: usize = 8192;
pub const PROCESS_SAMPLE_LIMIT: usize = 16_384;
pub const CPU_SAMPLE_LIMIT: usize = 4096;
pub const USER_CACHE_LIMIT: usize = 8192;
#[derive(Clone, Debug, Default)]
pub struct SystemView {
pub cpu_total: f32,
pub cpus: Vec<CpuCore>,
pub memory: MemInfo,
pub load: LoadAverage,
pub states: StateCounts,
pub limits: SampleLimits,
pub processes: Vec<ProcessInfo>,
pub thread_count: u64,
}
#[derive(Clone, Debug, Default)]
pub struct CpuCore {
pub name: String,
pub usage: f32,
}
#[derive(Clone, Debug, Default)]
pub struct MemInfo {
pub total: u64,
pub available: u64,
pub used: u64,
pub swap_total: u64,
pub swap_used: u64,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct LoadAverage {
pub one: f32,
pub five: f32,
pub fifteen: f32,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct SampleLimits {
pub process_limit_hit: bool,
pub cpu_limit_hit: bool,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct StateCounts {
pub running: u64,
pub sleeping: u64,
pub disk_sleep: u64,
pub stopped: u64,
pub zombie: u64,
pub idle: u64,
pub other: u64,
}
impl StateCounts {
fn record(&mut self, state: char) {
match state {
'R' => self.running += 1,
'S' => self.sleeping += 1,
'D' => self.disk_sleep += 1,
'T' | 't' => self.stopped += 1,
'Z' => self.zombie += 1,
'I' => self.idle += 1,
_ => self.other += 1,
}
}
}
#[derive(Clone, Debug)]
pub struct ProcessInfo {
pub pid: u32,
pub ppid: u32,
pub uid: u32,
pub user: Arc<str>,
pub name: Arc<str>,
pub cmd: Arc<str>,
pub user_lower: Arc<str>,
pub name_lower: Arc<str>,
pub cmd_lower: Arc<str>,
pub search_text: Arc<str>,
pub state: char,
pub cpu: f32,
pub mem: f32,
pub rss: u64,
pub vms: u64,
pub threads: u32,
pub runtime: u64,
pub started_after_boot: u64,
start_time: u64,
ticks: u64,
}
#[derive(Clone, Debug)]
struct ProcessMeta {
start_time: u64,
uid: u32,
user: Arc<str>,
name: Arc<str>,
cmd: Arc<str>,
user_lower: Arc<str>,
name_lower: Arc<str>,
cmd_lower: Arc<str>,
search_text: Arc<str>,
}
#[derive(Clone, Copy, Debug, Default)]
struct CpuTimes {
user: u64,
nice: u64,
system: u64,
idle: u64,
iowait: u64,
irq: u64,
softirq: u64,
steal: u64,
guest: u64,
guest_nice: u64,
}
impl CpuTimes {
fn total(self) -> u64 {
self.user
+ self.nice
+ self.system
+ self.idle
+ self.iowait
+ self.irq
+ self.softirq
+ self.steal
+ self.guest
+ self.guest_nice
}
fn idle_all(self) -> u64 {
self.idle + self.iowait
}
}
pub struct Sampler {
previous_cpu: Vec<(String, CpuTimes)>,
previous_proc_ticks: HashMap<u32, (u64, u64)>,
metadata: HashMap<u32, ProcessMeta>,
users: HashMap<u32, String>,
page_size: u64,
ticks_per_second: u64,
}
impl Sampler {
pub fn new() -> Self {
Self {
previous_cpu: Vec::new(),
previous_proc_ticks: HashMap::new(),
metadata: HashMap::new(),
users: load_users(),
page_size: page_size(),
ticks_per_second: clock_ticks_per_second(),
}
}
pub fn sample(&mut self) -> io::Result<SystemView> {
let memory = read_memory()?;
let load = read_loadavg().unwrap_or_default();
let uptime = read_uptime().unwrap_or(0.0);
let (cpu_times, cpu_limit_hit) = read_cpu_times()?;
let cpu_total = usage_between(self.previous_cpu.first(), cpu_times.first());
let cpus = cpu_times
.iter()
.enumerate()
.skip(1)
.map(|(index, (name, times))| {
let label = name
.strip_prefix("cpu")
.filter(|label| !label.is_empty())
.map(str::to_string)
.unwrap_or_else(|| index.to_string());
CpuCore {
name: label,
usage: usage_between(
self.previous_cpu.get(index),
Some(&(name.clone(), *times)),
),
}
})
.collect::<Vec<_>>();
let total_delta = match (self.previous_cpu.first(), cpu_times.first()) {
(Some((_, previous)), Some((_, current))) => {
current.total().saturating_sub(previous.total())
}
_ => 0,
};
let cores = cpus.len().max(1) as f32;
let cpu_window = if total_delta > 0 {
total_delta as f32 / cores
} else {
0.0
};
let mut next_proc_ticks =
HashMap::with_capacity(self.previous_proc_ticks.len().min(PROCESS_SAMPLE_LIMIT));
let mut processes =
Vec::with_capacity(self.previous_proc_ticks.len().min(PROCESS_SAMPLE_LIMIT));
let mut thread_count = 0_u64;
let mut states = StateCounts::default();
let mut process_limit_hit = false;
for entry in fs::read_dir("/proc")? {
if processes.len() >= PROCESS_SAMPLE_LIMIT {
process_limit_hit = true;
break;
}
let entry = match entry {
Ok(entry) => entry,
Err(_) => continue,
};
let file_name = entry.file_name();
let Some(pid) = file_name.to_string_lossy().parse::<u32>().ok() else {
continue;
};
let Some(mut process) = self.read_process(pid, memory.total, uptime) else {
continue;
};
if let Some((previous_start, previous_ticks)) = self.previous_proc_ticks.get(&pid) {
if *previous_start == process.start_time {
let delta = process.ticks.saturating_sub(*previous_ticks);
process.cpu = if cpu_window > 0.0 {
(delta as f32 / cpu_window) * 100.0
} else {
0.0
};
}
}
thread_count += process.threads as u64;
states.record(process.state);
next_proc_ticks.insert(pid, (process.start_time, process.ticks));
processes.push(process);
if processes.len() >= PROCESS_SAMPLE_LIMIT {
process_limit_hit = true;
}
}
self.previous_cpu = cpu_times;
self.previous_proc_ticks = next_proc_ticks;
self.metadata
.retain(|pid, meta| match self.previous_proc_ticks.get(pid) {
Some((start_time, _)) => *start_time == meta.start_time,
None => false,
});
Ok(SystemView {
cpu_total,
cpus,
memory,
load,
states,
limits: SampleLimits {
process_limit_hit,
cpu_limit_hit,
},
processes,
thread_count,
})
}
pub fn cached_processes(&self) -> usize {
self.metadata.len()
}
pub fn cached_users(&self) -> usize {
self.users.len()
}
fn read_process(&mut self, pid: u32, total_memory: u64, uptime: f64) -> Option<ProcessInfo> {
let stat =
read_to_string_limited(format!("/proc/{pid}/stat"), PROC_PID_STAT_BYTES_LIMIT).ok()?;
let parsed = parse_stat(&stat)?;
let refresh_meta = match self.metadata.get(&pid) {
Some(meta) => meta.start_time != parsed.start_time,
None => true,
};
if refresh_meta {
let meta = read_process_meta(
pid,
parsed.start_time,
parsed.name,
parsed.ppid,
&self.users,
);
self.metadata.insert(pid, meta);
}
let meta = self.metadata.get(&pid)?;
let rss = parsed.rss_pages.saturating_mul(self.page_size);
let mem = if total_memory > 0 {
(rss as f32 / total_memory as f32) * 100.0
} else {
0.0
};
let started_after_boot = parsed.start_time / self.ticks_per_second.max(1);
let runtime =
uptime.max(0.0).round().max(started_after_boot as f64) as u64 - started_after_boot;
Some(ProcessInfo {
pid,
ppid: parsed.ppid,
uid: meta.uid,
user: meta.user.clone(),
name: meta.name.clone(),
cmd: meta.cmd.clone(),
user_lower: meta.user_lower.clone(),
name_lower: meta.name_lower.clone(),
cmd_lower: meta.cmd_lower.clone(),
search_text: meta.search_text.clone(),
state: parsed.state,
cpu: 0.0,
mem,
rss,
vms: parsed.vms,
threads: parsed.threads,
runtime,
started_after_boot,
start_time: parsed.start_time,
ticks: parsed.ticks,
})
}
}
fn usage_between(
previous: Option<&(String, CpuTimes)>,
current: Option<&(String, CpuTimes)>,
) -> f32 {
let (Some((_, previous)), Some((_, current))) = (previous, current) else {
return 0.0;
};
let total = current.total().saturating_sub(previous.total());
if total == 0 {
return 0.0;
}
let idle = current.idle_all().saturating_sub(previous.idle_all());
((total.saturating_sub(idle)) as f32 / total as f32) * 100.0
}
fn read_cpu_times() -> io::Result<(Vec<(String, CpuTimes)>, bool)> {
let stat = read_to_string_limited("/proc/stat", PROC_STAT_BYTES_LIMIT)?;
let mut cpus = Vec::with_capacity(64);
let mut limit_hit = false;
for line in stat.lines() {
if !line.starts_with("cpu") {
break;
}
if cpus.len() >= CPU_SAMPLE_LIMIT {
limit_hit = true;
break;
}
let mut parts = line.split_whitespace();
let Some(name) = parts.next() else {
continue;
};
cpus.push((
name.to_string(),
CpuTimes {
user: parse_next_u64(&mut parts),
nice: parse_next_u64(&mut parts),
system: parse_next_u64(&mut parts),
idle: parse_next_u64(&mut parts),
iowait: parse_next_u64(&mut parts),
irq: parse_next_u64(&mut parts),
softirq: parse_next_u64(&mut parts),
steal: parse_next_u64(&mut parts),
guest: parse_next_u64(&mut parts),
guest_nice: parse_next_u64(&mut parts),
},
));
if cpus.len() >= CPU_SAMPLE_LIMIT {
limit_hit = true;
}
}
Ok((cpus, limit_hit))
}
fn read_memory() -> io::Result<MemInfo> {
let meminfo = read_to_string_limited("/proc/meminfo", PROC_MEMINFO_BYTES_LIMIT)?;
let mut total = 0;
let mut available = 0;
let mut swap_total = 0;
let mut swap_free = 0;
for line in meminfo.lines() {
let mut parts = line.split_whitespace();
let Some(key) = parts.next() else {
continue;
};
let value = parts
.next()
.and_then(|part| part.parse::<u64>().ok())
.unwrap_or(0)
* 1024;
match key {
"MemTotal:" => total = value,
"MemAvailable:" => available = value,
"SwapTotal:" => swap_total = value,
"SwapFree:" => swap_free = value,
_ => {}
}
}
let used = total.saturating_sub(available);
Ok(MemInfo {
total,
available,
used,
swap_total,
swap_used: swap_total.saturating_sub(swap_free),
})
}
fn read_loadavg() -> io::Result<LoadAverage> {
let loadavg = read_to_string_limited("/proc/loadavg", PROC_LOADAVG_BYTES_LIMIT)?;
let mut parts = loadavg.split_whitespace();
Ok(LoadAverage {
one: parts
.next()
.and_then(|part| part.parse().ok())
.unwrap_or(0.0),
five: parts
.next()
.and_then(|part| part.parse().ok())
.unwrap_or(0.0),
fifteen: parts
.next()
.and_then(|part| part.parse().ok())
.unwrap_or(0.0),
})
}
fn read_uptime() -> io::Result<f64> {
let uptime = read_to_string_limited("/proc/uptime", PROC_UPTIME_BYTES_LIMIT)?;
Ok(uptime
.split_whitespace()
.next()
.and_then(|part| part.parse::<f64>().ok())
.unwrap_or(0.0))
}
fn clock_ticks_per_second() -> u64 {
let ticks = unsafe { libc::sysconf(libc::_SC_CLK_TCK) };
if ticks > 0 {
ticks as u64
} else {
100
}
}
fn page_size() -> u64 {
let size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
if size > 0 {
size as u64
} else {
4096
}
}
struct ParsedStat<'a> {
name: &'a str,
state: char,
ppid: u32,
ticks: u64,
threads: u32,
start_time: u64,
vms: u64,
rss_pages: u64,
}
fn parse_stat(stat: &str) -> Option<ParsedStat<'_>> {
let open = stat.find('(')?;
let close = stat.rfind(')')?;
let name = stat.get(open + 1..close)?;
let mut fields = stat.get(close + 2..)?.split_whitespace();
let state = fields.next()?.chars().next().unwrap_or('?');
let ppid = fields.next()?.parse::<u32>().ok()?;
for _ in 0..9 {
fields.next()?;
}
let utime = fields.next()?.parse::<u64>().ok()?;
let stime = fields.next()?.parse::<u64>().ok()?;
for _ in 0..4 {
fields.next()?;
}
let threads = fields.next()?.parse::<u32>().unwrap_or(1);
fields.next()?;
let start_time = fields.next()?.parse::<u64>().ok()?;
let vms = fields.next()?.parse::<u64>().unwrap_or(0);
let rss_pages = fields.next()?.parse::<i64>().unwrap_or(0).max(0) as u64;
Some(ParsedStat {
name,
state,
ppid,
ticks: utime.saturating_add(stime),
threads,
start_time,
vms,
rss_pages,
})
}
fn read_process_meta(
pid: u32,
start_time: u64,
name: &str,
ppid: u32,
users: &HashMap<u32, String>,
) -> ProcessMeta {
let uid = read_uid(pid).unwrap_or(0);
let user = truncate_to_bytes(
&users.get(&uid).cloned().unwrap_or_else(|| uid.to_string()),
USER_RETAIN_LIMIT,
);
let name = truncate_to_bytes(name, PROCESS_NAME_RETAIN_LIMIT);
let cmd = read_cmdline(pid)
.map(|cmd| truncate_to_bytes(&cmd, CMD_RETAIN_LIMIT))
.unwrap_or_else(|| format!("[{name}]"));
let user_lower = truncate_to_bytes(&user.to_ascii_lowercase(), USER_RETAIN_LIMIT);
let name_lower = truncate_to_bytes(&name.to_ascii_lowercase(), PROCESS_NAME_RETAIN_LIMIT);
let cmd_lower = truncate_to_bytes(&cmd.to_ascii_lowercase(), CMD_RETAIN_LIMIT);
let search_text = truncate_to_bytes(
&format!("{pid} {ppid} {uid} {user} {name} {cmd}").to_ascii_lowercase(),
SEARCH_TEXT_RETAIN_LIMIT,
);
ProcessMeta {
start_time,
uid,
user: Arc::from(user),
name: Arc::from(name),
cmd: Arc::from(cmd),
user_lower: Arc::from(user_lower),
name_lower: Arc::from(name_lower),
cmd_lower: Arc::from(cmd_lower),
search_text: Arc::from(search_text),
}
}
fn read_uid(pid: u32) -> Option<u32> {
Some(fs::metadata(format!("/proc/{pid}")).ok()?.uid())
}
fn read_to_string_limited(path: impl AsRef<Path>, limit: usize) -> io::Result<String> {
let mut file = fs::File::open(path)?;
let mut text = String::new();
file.by_ref().take(limit as u64).read_to_string(&mut text)?;
Ok(text)
}
fn truncate_to_bytes(input: &str, limit: usize) -> String {
if input.len() <= limit {
return input.to_string();
}
let mut end = limit;
while end > 0 && !input.is_char_boundary(end) {
end -= 1;
}
input[..end].to_string()
}
fn read_cmdline(pid: u32) -> Option<String> {
let mut file = fs::File::open(format!("/proc/{pid}/cmdline")).ok()?;
let mut bytes = Vec::with_capacity(256);
file.by_ref()
.take(CMDLINE_LIMIT as u64)
.read_to_end(&mut bytes)
.ok()?;
let mut cmd = String::new();
for part in bytes
.split(|byte| *byte == 0)
.filter(|part| !part.is_empty())
{
if !cmd.is_empty() {
cmd.push(' ');
}
cmd.push_str(&String::from_utf8_lossy(part));
}
if cmd.is_empty() {
None
} else {
Some(cmd)
}
}
fn load_users() -> HashMap<u32, String> {
let mut users = HashMap::new();
let Ok(mut file) = fs::File::open(Path::new("/etc/passwd")) else {
return users;
};
let mut passwd = String::new();
if file
.by_ref()
.take(PASSWD_BYTES_LIMIT as u64)
.read_to_string(&mut passwd)
.is_err()
{
return users;
}
for line in passwd.lines().take(USER_CACHE_LIMIT) {
let mut fields = line.split(':');
let Some(name) = fields.next() else {
continue;
};
let Some(uid) = fields.nth(1).and_then(|field| field.parse::<u32>().ok()) else {
continue;
};
if users.len() < USER_CACHE_LIMIT {
users.insert(uid, name.to_string());
}
}
users
}
fn parse_next_u64(parts: &mut std::str::SplitWhitespace<'_>) -> u64 {
parts
.next()
.and_then(|part| part.parse::<u64>().ok())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn limited_read_caps_text_bytes() {
let path =
std::env::temp_dir().join(format!("ktop-limited-read-{}-text", std::process::id()));
fs::write(&path, "abcdef").unwrap();
let text = read_to_string_limited(&path, 3).unwrap();
let _ = fs::remove_file(&path);
assert_eq!(text, "abc");
}
#[test]
fn limited_read_allows_exact_size() {
let path =
std::env::temp_dir().join(format!("ktop-limited-read-{}-exact", std::process::id()));
fs::write(&path, "abcdef").unwrap();
let text = read_to_string_limited(&path, 6).unwrap();
let _ = fs::remove_file(&path);
assert_eq!(text, "abcdef");
}
#[test]
fn retained_text_truncates_on_utf8_boundary() {
assert_eq!(truncate_to_bytes("abcdef", 4), "abcd");
assert_eq!(truncate_to_bytes("ééé", 3), "é");
}
#[test]
fn retained_text_allows_exact_size() {
assert_eq!(truncate_to_bytes("abcd", 4), "abcd");
}
}