diff --git a/Cargo.lock b/Cargo.lock index 76249ed5..178c42a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -259,6 +259,7 @@ dependencies = [ "log", "once_cell", "predicates", + "procfs", "regex", "serde", "sysinfo", @@ -399,6 +400,15 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea221b5284a47e40033bf9b66f35f984ec0ea2931eb03505246cd27a963f981b" +[[package]] +name = "crc32fast" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81156fece84ab6a9f2afdb109ce3ae577e42b1228441eded99bd77f627953b1a" +dependencies = [ + "cfg-if 1.0.0", +] + [[package]] name = "crossbeam-channel" version = "0.5.0" @@ -543,6 +553,18 @@ dependencies = [ "log", ] +[[package]] +name = "flate2" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd3aec53de10fe96d7d8c565eb17f2c687bb5518a2ec453b5b1252964526abe0" +dependencies = [ + "cfg-if 1.0.0", + "crc32fast", + "libc", + "miniz_oxide", +] + [[package]] name = "float-cmp" version = "0.8.0" @@ -835,6 +857,12 @@ dependencies = [ "libc", ] +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "indexmap" version = "1.6.2" @@ -1175,6 +1203,21 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "procfs" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8809e0c18450a2db0f236d2a44ec0b4c1412d0eb936233579f0990faa5d5cd" +dependencies = [ + "bitflags", + "byteorder", + "chrono", + "flate2", + "hex", + "lazy_static", + "libc", +] + [[package]] name = "quote" version = "1.0.7" diff --git a/Cargo.toml b/Cargo.toml index 0dfa4602..11e26521 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,17 +61,18 @@ unicode-segmentation = "1.7.1" unicode-width = "0.1" # For debugging only... disable on release builds with --no-default-target for no? TODO: Redo this. -fern = { version = "0.6.0", optional=true } -log = { version = "0.4.14", optional=true } +fern = { version = "0.6.0", optional = true } +log = { version = "0.4.14", optional = true } [target.'cfg(unix)'.dependencies] libc = "0.2.86" [target.'cfg(target_os = "linux")'.dependencies] heim = { version = "0.1.0-rc.1", features = ["cpu", "disk", "memory", "net", "sensors"] } +procfs = "0.9.1" [target.'cfg(target_os = "macos")'.dependencies] -heim = { version = "0.1.0-rc.1", features = ["cpu", "disk", "memory", "net"] } +heim = { version = "0.1.0-rc.1", features = ["cpu", "disk", "memory", "net"] } [target.'cfg(target_os = "windows")'.dependencies] heim = { version = "0.1.0-rc.1", features = ["cpu", "disk", "memory"] } @@ -89,8 +90,16 @@ section = "utility" assets = [ ["target/release/btm", "usr/bin/", "755"], ["LICENSE", "usr/share/doc/btm/", "644"], - ["completion/btm.bash", "usr/share/bash-completion/completions/btm", "644"], - ["completion/btm.fish", "usr/share/fish/vendor_completions.d/btm.fish", "644"], + [ + "completion/btm.bash", + "usr/share/bash-completion/completions/btm", + "644", + ], + [ + "completion/btm.fish", + "usr/share/fish/vendor_completions.d/btm.fish", + "644", + ], ["completion/_btm", "usr/share/zsh/vendor-completions/", "644"], ] extended-description = """\ @@ -109,4 +118,3 @@ output = "bottom_x86_64_installer.msi" version = "1" default-features = false features = ["user-hooks"] - diff --git a/src/app/data_harvester.rs b/src/app/data_harvester.rs index 209cbb05..60ff5a2a 100644 --- a/src/app/data_harvester.rs +++ b/src/app/data_harvester.rs @@ -98,8 +98,6 @@ pub struct DataCollector { widgets_to_harvest: UsedWidgets, battery_manager: Option, battery_list: Option>, - #[cfg(target_os = "linux")] - page_file_size_kb: u64, filters: DataFilters, } @@ -127,13 +125,6 @@ impl DataCollector { widgets_to_harvest: UsedWidgets::default(), battery_manager: None, battery_list: None, - #[cfg(target_os = "linux")] - page_file_size_kb: unsafe { - // let page_file_size_kb = libc::sysconf(libc::_SC_PAGESIZE) as u64 / 1024; - // trace!("Page file size in KB: {}", page_file_size_kb); - // page_file_size_kb - libc::sysconf(libc::_SC_PAGESIZE) as u64 / 1024 - }, filters, } } @@ -268,7 +259,6 @@ impl DataCollector { .duration_since(self.last_collection_time) .as_secs(), self.mem_total_kb, - self.page_file_size_kb, ) } #[cfg(not(target_os = "linux"))] diff --git a/src/app/data_harvester/processes.rs b/src/app/data_harvester/processes.rs index 42c6ac58..94f6fb62 100644 --- a/src/app/data_harvester/processes.rs +++ b/src/app/data_harvester/processes.rs @@ -1,13 +1,13 @@ use crate::Pid; -use std::path::PathBuf; -use sysinfo::ProcessStatus; -#[cfg(target_os = "linux")] -use std::path::Path; +use sysinfo::ProcessStatus; #[cfg(target_family = "unix")] use crate::utils::error; +#[cfg(target_os = "linux")] +use procfs::process::{Process, Stat}; + #[cfg(target_os = "linux")] use crate::utils::error::BottomError; @@ -17,7 +17,8 @@ use fxhash::{FxHashMap, FxHashSet}; #[cfg(not(target_os = "linux"))] use sysinfo::{ProcessExt, ProcessorExt, System, SystemExt}; -/// Maximum character length of a /proc//stat process name that we'll accept. +/// Maximum character length of a /proc//stat process name. +/// If it's equal or greater, then we instead refer to the command for the name. #[cfg(target_os = "linux")] const MAX_STAT_NAME_LEN: usize = 15; @@ -90,38 +91,26 @@ pub struct ProcessHarvest { /// This is the *effective* user ID. #[cfg(target_family = "unix")] pub uid: Option, - - // TODO: Add real user ID - // pub real_uid: Option, - #[cfg(target_family = "unix")] - pub gid: Option, } -#[derive(Debug, Default, Clone)] +#[cfg(target_os = "linux")] +#[derive(Debug, Clone)] pub struct PrevProcDetails { pub total_read_bytes: u64, pub total_write_bytes: u64, - pub cpu_time: f64, - pub proc_stat_path: PathBuf, - pub proc_status_path: PathBuf, - // pub proc_statm_path: PathBuf, - // pub proc_exe_path: PathBuf, - pub proc_io_path: PathBuf, - pub proc_cmdline_path: PathBuf, - pub just_read: bool, + pub cpu_time: u64, + pub process: Process, } +#[cfg(target_os = "linux")] impl PrevProcDetails { - pub fn new(pid: Pid) -> Self { - PrevProcDetails { - proc_io_path: PathBuf::from(format!("/proc/{}/io", pid)), - // proc_exe_path: PathBuf::from(format!("/proc/{}/exe", pid)), - proc_stat_path: PathBuf::from(format!("/proc/{}/stat", pid)), - proc_status_path: PathBuf::from(format!("/proc/{}/status", pid)), - // proc_statm_path: PathBuf::from(format!("/proc/{}/statm", pid)), - proc_cmdline_path: PathBuf::from(format!("/proc/{}/cmdline", pid)), - ..PrevProcDetails::default() - } + fn new(pid: Pid) -> error::Result { + Ok(Self { + total_read_bytes: 0, + total_write_bytes: 0, + cpu_time: 0, + process: Process::new(pid)?, + }) } } @@ -214,60 +203,29 @@ fn cpu_usage_calculation( Ok((result, cpu_percentage)) } -#[cfg(target_os = "linux")] -fn get_linux_process_vsize_rss(stat: &[&str]) -> (u64, u64) { - // Represents vsize and rss (bytes and page numbers respectively) - ( - stat[20].parse::().unwrap_or(0), - stat[21].parse::().unwrap_or(0), - ) -} - -#[cfg(target_os = "linux")] -/// Preferably use this only on small files. -fn read_path_contents(path: &Path) -> std::io::Result { - std::fs::read_to_string(path) -} - -#[cfg(target_os = "linux")] -fn get_linux_process_state(stat: &[&str]) -> (char, String) { - // The -2 offset is because of us cutting off name + pid, normally it's 2 - if let Some(first_char) = stat[0].chars().collect::>().first() { - (*first_char, ProcessStatus::from(*first_char).to_string()) - } else { - ('?', String::default()) - } -} - -/// Note that cpu_fraction should be represented WITHOUT the x100 factor! +/// Returns the usage and a new set of process times. Note: cpu_fraction should be represented WITHOUT the x100 factor! #[cfg(target_os = "linux")] fn get_linux_cpu_usage( - proc_stats: &[&str], cpu_usage: f64, cpu_fraction: f64, prev_proc_val: &mut f64, + stat: &Stat, cpu_usage: f64, cpu_fraction: f64, prev_proc_times: u64, use_current_cpu_total: bool, -) -> std::io::Result { - fn get_process_cpu_stats(stat: &[&str]) -> f64 { - // utime + stime (matches top), the -2 offset is because of us cutting off name + pid (normally 13, 14) - stat[11].parse::().unwrap_or(0_f64) + stat[12].parse::().unwrap_or(0_f64) - } - +) -> (f64, u64) { // Based heavily on https://stackoverflow.com/a/23376195 and https://stackoverflow.com/a/1424556 - let new_proc_val = get_process_cpu_stats(&proc_stats); + let new_proc_times = stat.utime + stat.stime; + let diff = (new_proc_times - prev_proc_times) as f64; // I HATE that it's done like this but there isn't a try_from for u64 -> f64... we can accept a bit of loss in the worst case though if cpu_usage == 0.0 { - Ok(0_f64) + (0.0, new_proc_times) } else if use_current_cpu_total { - let res = Ok((new_proc_val - *prev_proc_val) / cpu_usage * 100_f64); - *prev_proc_val = new_proc_val; - res + (diff / cpu_usage * 100_f64, new_proc_times) } else { - let res = Ok((new_proc_val - *prev_proc_val) / cpu_usage * 100_f64 * cpu_fraction); - *prev_proc_val = new_proc_val; - res + (diff / cpu_usage * 100_f64 * cpu_fraction, new_proc_times) } } #[cfg(target_os = "macos")] -fn get_macos_cpu_usage(pids: &[i32]) -> std::io::Result> { +fn get_macos_process_cpu_usage( + pids: &[i32], +) -> std::io::Result> { use itertools::Itertools; let output = std::process::Command::new("ps") .args(&["-o", "pid=,pcpu=", "-p"]) @@ -296,164 +254,80 @@ fn get_macos_cpu_usage(pids: &[i32]) -> std::io::Result (Option, Option) { - // FIXME: [OPT] - can we merge our /stat and /status calls? - use std::io::prelude::*; - use std::io::BufReader; - - if let Ok(file) = std::fs::File::open(path) { - let reader = BufReader::new(file); - let mut lines = reader.lines().skip(8); - - let (_real_uid, effective_uid) = if let Some(Ok(read_uid_line)) = lines.next() { - let mut split_whitespace = read_uid_line.split_whitespace().skip(1); - ( - split_whitespace.next().and_then(|x| x.parse::().ok()), - split_whitespace.next().and_then(|x| x.parse::().ok()), - ) - } else { - (None, None) - }; - - let (_real_gid, effective_gid) = if let Some(Ok(read_gid_line)) = lines.next() { - let mut split_whitespace = read_gid_line.split_whitespace().skip(1); - ( - split_whitespace.next().and_then(|x| x.parse::().ok()), - split_whitespace.next().and_then(|x| x.parse::().ok()), - ) - } else { - (None, None) - }; - - (effective_uid, effective_gid) - } else { - (None, None) - } -} - #[allow(clippy::too_many_arguments)] #[cfg(target_os = "linux")] fn read_proc( - pid: Pid, cpu_usage: f64, cpu_fraction: f64, pid_mapping: &mut FxHashMap, + prev_proc: &PrevProcDetails, stat: &Stat, cpu_usage: f64, cpu_fraction: f64, use_current_cpu_total: bool, time_difference_in_secs: u64, mem_total_kb: u64, - page_file_kb: u64, -) -> error::Result { - use std::io::prelude::*; - use std::io::BufReader; +) -> error::Result<(ProcessHarvest, u64)> { + use std::convert::TryFrom; - let pid_stat = pid_mapping - .entry(pid) - .or_insert_with(|| PrevProcDetails::new(pid)); - let stat_results = read_path_contents(&pid_stat.proc_stat_path)?; + let process = &prev_proc.process; - // truncated_name may potentially be cut! Hence why we do the bit of code after... - let truncated_name = stat_results - .splitn(2, '(') - .collect::>() - .last() - .ok_or(BottomError::MinorError)? - .rsplitn(2, ')') - .collect::>() - .last() - .ok_or(BottomError::MinorError)? - .to_string(); let (command, name) = { - let cmd = read_path_contents(&pid_stat.proc_cmdline_path)?; - let trimmed_cmd = cmd.trim(); - if trimmed_cmd.is_empty() { - (format!("[{}]", truncated_name), truncated_name) - } else { - // We split by spaces and null terminators. - let separated_strings = trimmed_cmd - .split_terminator(|c| c == '\0' || c == ' ') - .collect::>(); - - ( - separated_strings.join(" "), - if truncated_name.len() >= MAX_STAT_NAME_LEN { - if let Some(first_part) = separated_strings.first() { - // We're only interested in the executable part... not the file path. - // That's for command. - first_part - .split('/') - .collect::>() - .last() - .unwrap_or(&truncated_name.as_str()) - .to_string() + let truncated_name = stat.comm.as_str(); + if let Ok(cmdline) = process.cmdline() { + if cmdline.is_empty() { + (format!("[{}]", truncated_name), truncated_name.to_string()) + } else { + ( + cmdline.join(" "), + if truncated_name.len() >= MAX_STAT_NAME_LEN { + if let Some(first_part) = cmdline.first() { + // We're only interested in the executable part... not the file path. + // That's for command. + first_part + .rsplit_once('/') + .map(|(_prefix, suffix)| suffix) + .unwrap_or(&truncated_name) + .to_string() + } else { + truncated_name.to_string() + } } else { - truncated_name - } - } else { - truncated_name - }, - ) + truncated_name.to_string() + }, + ) + } + } else { + (truncated_name.to_string(), truncated_name.to_string()) } }; - let stat = stat_results - .split(')') - .collect::>() - .last() - .ok_or(BottomError::MinorError)? - .split_whitespace() - .collect::>(); - let (process_state_char, process_state) = get_linux_process_state(&stat); - let cpu_usage_percent = get_linux_cpu_usage( + + let process_state_char = stat.state; + let process_state = ProcessStatus::from(process_state_char).to_string(); + let (cpu_usage_percent, new_process_times) = get_linux_cpu_usage( &stat, cpu_usage, cpu_fraction, - &mut pid_stat.cpu_time, + prev_proc.cpu_time, use_current_cpu_total, - )?; - let parent_pid = stat[1].parse::().ok(); - let (_vsize, rss) = get_linux_process_vsize_rss(&stat); - let mem_usage_kb = rss * page_file_kb; + ); + let parent_pid = Some(stat.ppid); + let mem_usage_bytes = u64::try_from(stat.rss_bytes()).unwrap_or(0); + let mem_usage_kb = mem_usage_bytes / 1024; let mem_usage_percent = mem_usage_kb as f64 / mem_total_kb as f64 * 100.0; - let mem_usage_bytes = mem_usage_kb * 1024; // This can fail if permission is denied! let (total_read_bytes, total_write_bytes, read_bytes_per_sec, write_bytes_per_sec) = - if let Ok(file) = std::fs::File::open(&pid_stat.proc_io_path) { - let reader = BufReader::new(file); - let mut lines = reader.lines().skip(4); - - // Represents read_bytes and write_bytes, at the 5th and 6th lines (1-index, not 0-index) - let total_read_bytes = if let Some(Ok(read_bytes_line)) = lines.next() { - if let Some(read_bytes) = read_bytes_line.split_whitespace().last() { - read_bytes.parse::().unwrap_or(0) - } else { - 0 - } - } else { - 0 - }; - - let total_write_bytes = if let Some(Ok(write_bytes_line)) = lines.next() { - if let Some(write_bytes) = write_bytes_line.split_whitespace().last() { - write_bytes.parse::().unwrap_or(0) - } else { - 0 - } - } else { - 0 - }; + if let Ok(io) = process.io() { + let total_read_bytes = io.read_bytes; + let total_write_bytes = io.write_bytes; let read_bytes_per_sec = if time_difference_in_secs == 0 { 0 } else { - total_read_bytes.saturating_sub(pid_stat.total_read_bytes) / time_difference_in_secs + total_read_bytes.saturating_sub(prev_proc.total_read_bytes) + / time_difference_in_secs }; let write_bytes_per_sec = if time_difference_in_secs == 0 { 0 } else { - total_write_bytes.saturating_sub(pid_stat.total_write_bytes) + total_write_bytes.saturating_sub(prev_proc.total_write_bytes) / time_difference_in_secs }; - pid_stat.total_read_bytes = total_read_bytes; - pid_stat.total_write_bytes = total_write_bytes; - ( total_read_bytes, total_write_bytes, @@ -464,55 +338,86 @@ fn read_proc( (0, 0, 0, 0) }; - let (uid, gid) = get_uid_and_gid(&pid_stat.proc_status_path); + let uid = Some(process.owner); - Ok(ProcessHarvest { - pid, - parent_pid, - cpu_usage_percent, - mem_usage_percent, - mem_usage_bytes, - name, - command, - read_bytes_per_sec, - write_bytes_per_sec, - total_read_bytes, - total_write_bytes, - process_state, - process_state_char, - uid, - gid, - }) + Ok(( + ProcessHarvest { + pid: process.pid, + parent_pid, + cpu_usage_percent, + mem_usage_percent, + mem_usage_bytes, + name, + command, + read_bytes_per_sec, + write_bytes_per_sec, + total_read_bytes, + total_write_bytes, + process_state, + process_state_char, + uid, + }, + new_process_times, + )) } #[cfg(target_os = "linux")] pub fn get_process_data( prev_idle: &mut f64, prev_non_idle: &mut f64, pid_mapping: &mut FxHashMap, use_current_cpu_total: bool, - time_difference_in_secs: u64, mem_total_kb: u64, page_file_kb: u64, + time_difference_in_secs: u64, mem_total_kb: u64, ) -> crate::utils::error::Result> { // TODO: [PROC THREADS] Add threads if let Ok((cpu_usage, cpu_fraction)) = cpu_usage_calculation(prev_idle, prev_non_idle) { let mut pids_to_clear: FxHashSet = pid_mapping.keys().cloned().collect(); + let process_vector: Vec = std::fs::read_dir("/proc")? .filter_map(|dir| { if let Ok(dir) = dir { - let pid = dir.file_name().to_string_lossy().trim().parse::(); - if let Ok(pid) = pid { - // I skip checking if the path is also a directory, it's not needed I think? - if let Ok(process_object) = read_proc( - pid, - cpu_usage, - cpu_fraction, - pid_mapping, - use_current_cpu_total, - time_difference_in_secs, - mem_total_kb, - page_file_kb, - ) { - pids_to_clear.remove(&pid); - return Some(process_object); + if let Ok(pid) = dir.file_name().to_string_lossy().trim().parse::() { + let mut fresh = false; + if !pid_mapping.contains_key(&pid) { + if let Ok(ppd) = PrevProcDetails::new(pid) { + pid_mapping.insert(pid, ppd); + fresh = true; + } else { + // Bail early. + return None; + } + }; + + if let Some(prev_proc_details) = pid_mapping.get_mut(&pid) { + let stat; + let stat_live; + if fresh { + stat = &prev_proc_details.process.stat; + } else if let Ok(s) = prev_proc_details.process.stat() { + stat_live = s; + stat = &stat_live; + } else { + // Bail early. + return None; + } + + if let Ok((process_harvest, new_process_times)) = read_proc( + &prev_proc_details, + stat, + cpu_usage, + cpu_fraction, + use_current_cpu_total, + time_difference_in_secs, + mem_total_kb, + ) { + prev_proc_details.cpu_time = new_process_times; + prev_proc_details.total_read_bytes = + process_harvest.total_read_bytes; + prev_proc_details.total_write_bytes = + process_harvest.total_write_bytes; + + pids_to_clear.remove(&pid); + return Some(process_harvest); + } } } } @@ -604,7 +509,6 @@ pub fn get_process_data( process_state: process_val.status().to_string(), process_state_char: convert_process_status_to_char(process_val.status()), uid: Some(process_val.uid), - gid: Some(process_val.gid), }); } #[cfg(not(target_os = "macos"))] @@ -639,7 +543,7 @@ pub fn get_process_data( .filter(|process| process.process_state == unknown_state) .map(|process| process.pid) .collect(); - let cpu_usages = get_macos_cpu_usage(&cpu_usage_unknown_pids)?; + let cpu_usages = get_macos_process_cpu_usage(&cpu_usage_unknown_pids)?; for process in &mut process_vector { if cpu_usages.contains_key(&process.pid) { process.cpu_usage_percent = if num_cpus == 0.0 { diff --git a/src/utils/error.rs b/src/utils/error.rs index c9273438..8f8da789 100644 --- a/src/utils/error.rs +++ b/src/utils/error.rs @@ -2,6 +2,9 @@ use beef::Cow; use std::result; use thiserror::Error; +#[cfg(target_os = "linux")] +use procfs::ProcError; + /// A type alias for handling errors related to Bottom. pub type Result = result::Result; @@ -35,6 +38,10 @@ pub enum BottomError { /// An error that just signifies something minor went wrong; no message. #[error("Minor error.")] MinorError, + /// An error to represent errors with procfs + #[cfg(target_os = "linux")] + #[error("Procfs error, {0}")] + ProcfsError(String), } impl From for BottomError { @@ -107,3 +114,23 @@ impl From for BottomError { ) } } + +#[cfg(target_os = "linux")] +impl From for BottomError { + fn from(err: ProcError) -> Self { + match err { + ProcError::PermissionDenied(p) => { + BottomError::ProcfsError(format!("Permission denied for {:?}", p)) + } + ProcError::NotFound(p) => BottomError::ProcfsError(format!("{:?} not found", p)), + ProcError::Incomplete(p) => BottomError::ProcfsError(format!("{:?} incomplete", p)), + ProcError::Io(e, p) => { + BottomError::ProcfsError(format!("io error: {:?} for {:?}", e, p)) + } + ProcError::Other(s) => BottomError::ProcfsError(format!("Other procfs error: {}", s)), + ProcError::InternalError(e) => { + BottomError::ProcfsError(format!("procfs internal error: {:?}", e)) + } + } + } +}