feature: add process swap column for Linux (#2224)

Adds Linux support for process swap tracking + a column in the widget.
This commit is contained in:
Abu Hurayra Niloy
2026-09-21 17:22:46 -04:00
committed by GitHub
parent 8ddd0aa00e
commit 4136d204bc
16 changed files with 199 additions and 10 deletions
+1 -1
View File
@@ -158,7 +158,7 @@
# Processes widget configuration
#[processes]
# The columns shown by the process widget. The following columns are supported (the GPU columns are only available if the GPU feature is enabled when built):
# PID, Name, CPU%, Mem%, R/s, W/s, T.Read, T.Write, User, State, Time, GMem%, GPU%, Nice, Priority
# PID, Name, CPU%, Mem%, Swap, R/s, W/s, T.Read, T.Write, User, State, Time, GMem%, GPU%, Nice, Priority
#columns = ["PID", "Name", "CPU%", "Mem%", "Virt", "R/s", "W/s", "T.Read", "T.Write", "User", "State", "GMem%", "GPU%", "Priority"]
# The default sort column when bottom starts. Accepts any of the column names above.
+2
View File
@@ -1039,6 +1039,7 @@
"Read",
"Rps",
"State",
"Swap",
"T.Read",
"T.Write",
"TRead",
@@ -1072,6 +1073,7 @@
"read",
"rps",
"state",
"swap",
"t.read",
"t.write",
"time",
+1
View File
@@ -48,6 +48,7 @@ pub struct AppConfigFields {
pub use_current_cpu_total: bool,
pub unnormalized_cpu: bool,
pub get_process_threads: bool,
pub get_process_swap: bool,
pub use_basic_mode: bool,
pub default_time_value: u64,
pub time_interval: u64,
+6
View File
@@ -168,6 +168,7 @@ pub struct DataCollector {
use_current_cpu_total: bool,
show_average_cpu: bool,
get_process_threads: bool,
get_process_swap: bool,
last_list_collection_time: Instant,
should_run_less_routine_tasks: bool,
@@ -226,6 +227,7 @@ impl DataCollector {
use_current_cpu_total: false,
unnormalized_cpu: false,
get_process_threads: false,
get_process_swap: false,
last_collection_time,
total_rx: 0,
total_tx: 0,
@@ -300,6 +302,10 @@ impl DataCollector {
self.get_process_threads = get_process_threads;
}
pub fn set_get_process_swap(&mut self, get_process_swap: bool) {
self.get_process_swap = get_process_swap;
}
pub fn set_include_unmounted_disks(&mut self, include_unmounted_disks: bool) {
self.include_unmounted_disks = include_unmounted_disks;
}
+3
View File
@@ -106,6 +106,9 @@ pub struct ProcessHarvest {
/// Virtual memory.
pub virtual_mem: Bytes,
/// Swapped memory.
pub swap_bytes: Option<Bytes>,
/// The name of the process.
pub name: String,
+15 -4
View File
@@ -142,6 +142,7 @@ fn read_proc(
stat,
io,
cmdline,
swap_bytes,
} = process;
let ReadProcArgs {
@@ -152,6 +153,7 @@ fn read_proc(
time_difference_in_secs,
system_uptime,
get_process_threads: _,
get_process_swap: _,
} = args;
let process_state_char = stat.state;
@@ -272,6 +274,7 @@ fn read_proc(
mem_usage_percent,
mem_usage,
virtual_mem,
swap_bytes,
name,
command,
read_per_sec,
@@ -349,6 +352,7 @@ pub(crate) struct ReadProcArgs {
pub time_difference_in_secs: u64,
pub system_uptime: u64,
pub get_process_threads: bool,
pub get_process_swap: bool,
}
pub(crate) fn linux_process_data(
@@ -364,6 +368,8 @@ pub(crate) fn linux_process_data(
unnormalized_cpu: collector.unnormalized_cpu,
get_process_threads: collector.get_process_threads,
};
let get_swap = collector.get_process_swap;
let prev_process_details = &mut collector.prev_process_details;
let user_table = &mut collector.user_table;
@@ -420,6 +426,7 @@ pub(crate) fn linux_process_data(
time_difference_in_secs,
system_uptime: sysinfo::System::uptime(),
get_process_threads: get_threads,
get_process_swap: get_swap,
};
// TODO: Maybe pre-allocate these buffers in the future w/ routine cleanup.
@@ -428,9 +435,12 @@ pub(crate) fn linux_process_data(
let mut process_vector: Vec<ProcessHarvest> = pids
.filter_map(|pid_path| {
if let Ok((process, threads)) =
Process::from_path(pid_path, &mut buffer, args.get_process_threads)
{
if let Ok((process, threads)) = Process::from_path(
pid_path,
&mut buffer,
args.get_process_threads,
args.get_process_swap,
) {
let pid = process.pid;
let prev_proc_details = prev_process_details.entry(pid).or_default();
@@ -474,7 +484,8 @@ pub(crate) fn linux_process_data(
// Get thread data.
for (pid, tid_paths) in process_threads_to_check {
for tid_path in tid_paths {
if let Ok((process, _)) = Process::from_path(tid_path, &mut buffer, false) {
// VmSwap is process-wide, so don't collect it for individual threads.
if let Ok((process, _)) = Process::from_path(tid_path, &mut buffer, false, false) {
let tid = process.pid;
let prev_proc_details = prev_process_details.entry(tid).or_default();
+80 -1
View File
@@ -246,6 +246,33 @@ impl Io {
}
}
/// Helper that reads the `VmSwap` line from `/proc/<PID>/status`.
///
/// NB: `buffer` must be empty.
///
/// See the [`proc_pid_status(5)`](https://man7.org/linux/man-pages/man5/proc_pid_status.5.html)
/// documentation for details about this file and field.
fn get_swap_bytes(f: File, buffer: &mut String) -> anyhow::Result<u64> {
let mut reader = BufReader::new(f);
while reader.read_line(buffer)? > 0 {
let mut parts = buffer.split_whitespace();
if parts.next() == Some("VmSwap:") {
let swap_kib: u64 = parts
.next()
.ok_or_else(|| anyhow!("VmSwap value missing"))?
.parse()?;
return Ok(swap_kib.saturating_mul(1024));
}
buffer.clear();
}
Err(anyhow!("VmSwap field not found"))
}
/// A wrapper around a Linux process operations in `/proc/<PID>`.
///
/// Core documentation based on [proc's manpages](https://man7.org/linux/man-pages/man5/proc.5.html).
@@ -255,6 +282,7 @@ pub(crate) struct Process {
pub stat: Stat,
pub io: Option<Io>,
pub cmdline: Option<String>,
pub swap_bytes: Option<u64>,
}
#[inline]
@@ -277,7 +305,7 @@ impl Process {
/// buffer.
#[inline]
pub(crate) fn from_path(
pid_path: PathBuf, buffer: &mut String, get_threads: bool,
pid_path: PathBuf, buffer: &mut String, get_threads: bool, get_swap: bool,
) -> anyhow::Result<(Process, Vec<PathBuf>)> {
buffer.clear();
@@ -333,6 +361,16 @@ impl Process {
reset(&mut root, buffer);
let swap_bytes = if get_swap && !stat.is_kernel_thread {
let bytes = open_at(&mut root, "status", &pid_dir)
.and_then(|file| get_swap_bytes(file, buffer))
.ok();
reset(&mut root, buffer);
bytes
} else {
None
};
let threads = threads(&mut root, pid, get_threads);
Ok((
@@ -342,6 +380,7 @@ impl Process {
stat,
io,
cmdline,
swap_bytes,
},
threads,
))
@@ -426,6 +465,14 @@ mod tests {
Stat::from_file(file, &mut String::new())
}
fn swap_file(status: &str) -> anyhow::Result<u64> {
let mut file = tempfile::tempfile()?;
file.write_all(status.as_bytes())?;
file.rewind()?;
get_swap_bytes(file, &mut String::new())
}
#[test]
fn parse_short_comm() {
let stat = stat_from_name("kworker/u16:2").unwrap();
@@ -463,4 +510,36 @@ mod tests {
assert!(stat_file("1 (blah)").is_err(), "too short");
assert!(stat_file("1 )(").is_err(), "wrong order");
}
#[test]
fn parse_swap_bytes() {
let status = "Name:\ttest\nVmSwap:\t4096 kB\n";
let swap_bytes = swap_file(status).unwrap();
assert_eq!(swap_bytes, 4_194_304);
}
#[test]
fn parse_zero_swap_bytes() {
let status = "Name:\ttest\nVmSwap:\t0 kB\n";
let swap_bytes = swap_file(status).unwrap();
assert_eq!(swap_bytes, 0);
}
#[test]
fn missing_vm_swap_is_an_error() {
let status = "Name:\ttest\nVmSize:\t4096 kB\n";
assert!(swap_file(status).is_err());
}
#[test]
fn invalid_vm_swap_is_an_error() {
let status = "Name:\ttest\nVmSwap:\tinvalid kB\n";
assert!(swap_file(status).is_err());
}
}
@@ -146,6 +146,7 @@ pub(crate) trait UnixProcessExt {
},
mem_usage: process_val.memory(),
virtual_mem: process_val.virtual_memory(),
swap_bytes: None,
cpu_usage_percent: process_cpu_usage,
read_per_sec: disk_usage.read_bytes,
write_per_sec: disk_usage.written_bytes,
+1
View File
@@ -136,6 +136,7 @@ pub fn sysinfo_process_data(
} as f32,
mem_usage: process.memory(),
virtual_mem: process.virtual_memory(),
swap_bytes: None,
cpu_usage_percent: process_cpu_usage,
read_per_sec: disk_usage.read_bytes,
write_per_sec: disk_usage.written_bytes,
+1 -1
View File
@@ -407,7 +407,7 @@ pub(crate) const CONFIG_TEXT: &str = r#"# This is a default config file for bott
# Processes widget configuration
#[processes]
# The columns shown by the process widget. The following columns are supported (the GPU columns are only available if the GPU feature is enabled when built):
# PID, Name, CPU%, Mem%, R/s, W/s, T.Read, T.Write, User, State, Time, GMem%, GPU%, Nice, Priority
# PID, Name, CPU%, Mem%, Swap, R/s, W/s, T.Read, T.Write, User, State, Time, GMem%, GPU%, Nice, Priority
#columns = ["PID", "Name", "CPU%", "Mem%", "Virt", "R/s", "W/s", "T.Read", "T.Write", "User", "State", "GMem%", "GPU%", "Priority"]
# The default sort column when bottom starts. Accepts any of the column names above.
+2
View File
@@ -225,6 +225,7 @@ fn create_collection_thread(
let show_average_cpu = app_config_fields.show_average_cpu;
let update_sleep = app_config_fields.update_rate;
let get_process_threads = app_config_fields.get_process_threads;
let get_process_swap = app_config_fields.get_process_swap;
#[cfg(feature = "zfs")]
let get_arc_free = app_config_fields.free_arc;
let include_unmounted_disks =
@@ -238,6 +239,7 @@ fn create_collection_thread(
data_collector.set_unnormalized_cpu(unnormalized_cpu);
data_collector.set_show_average_cpu(show_average_cpu);
data_collector.set_get_process_threads(get_process_threads);
data_collector.set_get_process_swap(get_process_swap);
#[cfg(feature = "zfs")]
data_collector.set_free_arc_mem(get_arc_free);
data_collector.set_include_unmounted_disks(include_unmounted_disks);
+3
View File
@@ -481,6 +481,9 @@ pub(crate) fn init_app(args: BottomArgs, config: Config) -> Result<(App, BottomL
flags.unnormalized_cpu,
),
get_process_threads: is_flag_enabled_in!(get_threads, args.process, config.processes),
get_process_swap: proc_columns
.as_ref()
.is_some_and(|columns| columns.contains(&ProcWidgetColumn::Swap)),
use_basic_mode,
default_time_value,
time_interval: get_time_interval(args, config, retention_ms)?,
+3 -2
View File
@@ -96,12 +96,12 @@ mod test {
fn valid_process_column_config() {
#[cfg(unix)]
let config = r#"
columns = ["CPU%", "PiD", "user", "MEM", "virt", "Tread", "T.Write", "Rps", "W/s", "tiMe", "USER", "state", "prioRity", "Nice"]
columns = ["CPU%", "PiD", "user", "MEM", "virt", "swap", "Tread", "T.Write", "Rps", "W/s", "tiMe", "USER", "state", "prioRity", "Nice"]
"#;
#[cfg(target_os = "windows")]
let config = r#"
columns = ["CPU%", "PiD", "user", "MEM", "virt", "Tread", "T.Write", "Rps", "W/s", "tiMe", "USER", "state", "prioRity"]
columns = ["CPU%", "PiD", "user", "MEM", "virt", "swap", "Tread", "T.Write", "Rps", "W/s", "tiMe", "USER", "state", "prioRity"]
"#;
let generated: ProcessesConfig = toml_edit::de::from_str(config).unwrap();
@@ -113,6 +113,7 @@ mod test {
ProcWidgetColumn::User,
ProcWidgetColumn::Mem,
ProcWidgetColumn::VirtualMem,
ProcWidgetColumn::Swap,
ProcWidgetColumn::TotalRead,
ProcWidgetColumn::TotalWrite,
ProcWidgetColumn::ReadPerSecond,
+29
View File
@@ -144,6 +144,7 @@ fn make_column(column: ProcColumn) -> SortColumn<ProcColumn> {
MemValue => SortColumn::new(MemValue).default_descending(),
MemPercent => SortColumn::new(MemPercent).default_descending(),
VirtualMem => SortColumn::new(VirtualMem).default_descending(),
Swap => SortColumn::new(Swap).default_descending(),
Pid => SortColumn::new(Pid),
Count => SortColumn::new(Count),
Name => SortColumn::soft(Name, Some(0.3)),
@@ -186,6 +187,7 @@ pub enum ProcWidgetColumn {
Cpu,
Mem,
VirtualMem,
Swap,
ReadPerSecond,
WritePerSecond,
TotalRead,
@@ -333,6 +335,7 @@ impl ProcWidgetState {
}
}
ProcWidgetColumn::VirtualMem => VirtualMem,
ProcWidgetColumn::Swap => Swap,
ProcWidgetColumn::ReadPerSecond => ReadPerSecond,
ProcWidgetColumn::WritePerSecond => WritePerSecond,
ProcWidgetColumn::TotalRead => TotalRead,
@@ -391,6 +394,7 @@ impl ProcWidgetState {
CpuPercent => ProcWidgetColumn::Cpu,
MemValue | MemPercent => ProcWidgetColumn::Mem,
VirtualMem => ProcWidgetColumn::VirtualMem,
Swap => ProcWidgetColumn::Swap,
Pid | Count => ProcWidgetColumn::PidOrCount,
Name | Command => ProcWidgetColumn::ProcNameOrCommand,
ReadPerSecond => ProcWidgetColumn::ReadPerSecond,
@@ -840,6 +844,12 @@ impl ProcWidgetState {
}
}
pwd.swap_bytes = match (pwd.swap_bytes, process.swap_bytes) {
(Some(a), Some(b)) => Some(a + b),
(Some(a), None) | (None, Some(a)) => Some(a),
(None, None) => None,
};
pwd.rps += process.read_per_sec;
pwd.wps += process.write_per_sec;
pwd.total_read += process.total_read;
@@ -1183,6 +1193,7 @@ mod test {
cpu_usage_percent: 0.0,
mem_usage: MemUsage::Percent(1.1),
virtual_mem: 100,
swap_bytes: Some(100),
rps: 0,
wps: 0,
total_read: 0,
@@ -1213,6 +1224,7 @@ mod test {
id: "B".into(),
cpu_usage_percent: 1.1,
mem_usage: MemUsage::Percent(2.2),
swap_bytes: Some(200),
..(a.clone())
};
@@ -1222,6 +1234,7 @@ mod test {
id: "C".into(),
cpu_usage_percent: 2.2,
mem_usage: MemUsage::Percent(0.0),
swap_bytes: Some(50),
..(a.clone())
};
@@ -1231,8 +1244,10 @@ mod test {
id: "D".into(),
cpu_usage_percent: 0.0,
mem_usage: MemUsage::Percent(0.0),
swap_bytes: None,
..(a.clone())
};
let mut data = vec![d.clone(), b.clone(), c.clone(), a.clone()];
// Assume we had sorted over by pid.
@@ -1265,6 +1280,20 @@ mod test {
[&c, &d, &a, &b].iter().map(|d| d.pid).collect::<Vec<_>>(),
data.iter().map(|d| d.pid).collect::<Vec<_>>(),
);
data.sort_by_key(|p| p.pid);
sort_skip_pid_asc(&ProcColumn::Swap, &mut data, SortOrder::Descending);
assert_eq!(
vec![2, 1, 3, 4],
data.iter().map(|process| process.pid).collect::<Vec<_>>(),
);
data.sort_by_key(|p| p.pid);
sort_skip_pid_asc(&ProcColumn::Swap, &mut data, SortOrder::Ascending);
assert_eq!(
vec![4, 3, 1, 2],
data.iter().map(|process| process.pid).collect::<Vec<_>>(),
);
}
fn get_columns(table: &ProcessTable) -> Vec<ProcColumn> {
@@ -19,6 +19,7 @@ pub enum ProcColumn {
MemValue,
MemPercent,
VirtualMem,
Swap,
Pid,
Count,
Name,
@@ -54,6 +55,7 @@ impl ProcColumn {
// TODO: Change this
ProcColumn::MemValue | ProcColumn::MemPercent => &["Mem", "Mem%", "Memory", "Memory%"],
ProcColumn::VirtualMem => &["Virt", "Virtual", "VirtMem", "Virtual Memory"],
ProcColumn::Swap => &["Swap"],
ProcColumn::ReadPerSecond => &["R/s", "Read", "Rps"],
ProcColumn::WritePerSecond => &["W/s", "Write", "Wps"],
ProcColumn::TotalRead => &["T.Read", "TRead", "Total Read"],
@@ -80,6 +82,7 @@ impl ColumnHeader for ProcColumn {
ProcColumn::MemValue => "Mem",
ProcColumn::MemPercent => "Mem%",
ProcColumn::VirtualMem => "Virt",
ProcColumn::Swap => "Swap",
ProcColumn::Pid => "PID",
ProcColumn::Count => "Count",
ProcColumn::Name => "Name",
@@ -136,6 +139,9 @@ impl SortsRow for ProcColumn {
ProcColumn::VirtualMem => {
data.sort_by(|a, b| sort_partial_fn(descending)(&a.virtual_mem, &b.virtual_mem));
}
ProcColumn::Swap => {
data.sort_by(|a, b| sort_partial_fn(descending)(&a.swap_bytes, &b.swap_bytes));
}
ProcColumn::Pid => {
data.sort_by(|a, b| sort_partial_fn(descending)(a.pid, b.pid));
}
@@ -209,6 +215,7 @@ impl ProcColumn {
"cpu%" => Some(ProcColumn::CpuPercent),
"mem" | "mem%" | "memory" | "memory%" => Some(ProcColumn::MemPercent),
"virt" | "virtual" | "virtmem" | "virtual memory" => Some(ProcColumn::VirtualMem),
"swap" => Some(ProcColumn::Swap),
"pid" => Some(ProcColumn::Pid),
"count" => Some(ProcColumn::Count),
"name" => Some(ProcColumn::Name),
@@ -251,6 +258,7 @@ impl From<&ProcColumn> for ProcWidgetColumn {
ProcColumn::CpuPercent => ProcWidgetColumn::Cpu,
ProcColumn::MemPercent | ProcColumn::MemValue => ProcWidgetColumn::Mem,
ProcColumn::VirtualMem => ProcWidgetColumn::VirtualMem,
ProcColumn::Swap => ProcWidgetColumn::Swap,
ProcColumn::ReadPerSecond => ProcWidgetColumn::ReadPerSecond,
ProcColumn::WritePerSecond => ProcWidgetColumn::WritePerSecond,
ProcColumn::TotalRead => ProcWidgetColumn::TotalRead,
+43 -1
View File
@@ -19,7 +19,7 @@ use crate::{
Painter,
components::data_table::{DataTableColumn, DataToCell},
},
collection::processes::{Pid, ProcessHarvest},
collection::processes::{Bytes, Pid, ProcessHarvest},
dec_bytes_per_second_string,
utils::data_units::{GIBI_LIMIT, GIGA_LIMIT, get_binary_bytes, get_decimal_bytes},
};
@@ -219,6 +219,7 @@ pub struct ProcWidgetData {
pub cpu_usage_percent: f32,
pub mem_usage: MemUsage,
pub virtual_mem: u64,
pub swap_bytes: Option<Bytes>,
pub rps: u64,
pub wps: u64,
pub total_read: u64,
@@ -265,6 +266,7 @@ impl ProcWidgetData {
cpu_usage_percent: process.cpu_usage_percent,
mem_usage,
virtual_mem: process.virtual_mem,
swap_bytes: process.swap_bytes,
rps: process.read_per_sec,
wps: process.write_per_sec,
total_read: process.total_read,
@@ -309,6 +311,11 @@ impl ProcWidgetData {
self.total_read += other.total_read;
self.total_write += other.total_write;
self.time = self.time.max(other.time);
self.swap_bytes = match (self.swap_bytes, other.swap_bytes) {
(Some(a), Some(b)) => Some(a + b),
(Some(a), None) | (None, Some(a)) => Some(a),
(None, None) => None,
};
#[cfg(feature = "gpu")]
{
self.gpu_mem_usage = self.gpu_mem_usage + other.gpu_mem_usage;
@@ -324,6 +331,10 @@ impl ProcWidgetData {
ProcColumn::CpuPercent => format!("{:.1}%", self.cpu_usage_percent),
ProcColumn::MemValue | ProcColumn::MemPercent => self.mem_usage.to_string(),
ProcColumn::VirtualMem => binary_byte_string(self.virtual_mem),
ProcColumn::Swap => self
.swap_bytes
.map(binary_byte_string)
.unwrap_or_else(|| "N/A".to_string()),
ProcColumn::Pid => self.pid.to_string(),
ProcColumn::Count => self.num_similar.to_string(),
ProcColumn::Name | ProcColumn::Command => self.id.to_prefixed_string(),
@@ -360,6 +371,11 @@ impl DataToCell<ProcColumn> for ProcWidgetData {
ProcColumn::CpuPercent => format!("{:.1}%", self.cpu_usage_percent).into(),
ProcColumn::MemValue | ProcColumn::MemPercent => self.mem_usage.to_string().into(),
ProcColumn::VirtualMem => binary_byte_string(self.virtual_mem).into(),
ProcColumn::Swap => self
.swap_bytes
.map(binary_byte_string)
.unwrap_or_else(|| "N/A".to_string())
.into(),
ProcColumn::Pid => self.pid.to_string().into(),
ProcColumn::Count => self.num_similar.to_string().into(),
ProcColumn::Name | ProcColumn::Command => self.id.to_prefixed_string().into(),
@@ -487,4 +503,30 @@ mod test {
"10.4TiB".to_string()
);
}
#[test]
fn test_swap_addition() {
let process = ProcessHarvest {
swap_bytes: Some(100),
..Default::default()
};
let other = ProcessHarvest {
swap_bytes: Some(200),
..Default::default()
};
let mut data = ProcWidgetData::from_data(&process, false, false);
let other = ProcWidgetData::from_data(&other, false, false);
data.add(&other);
assert_eq!(data.swap_bytes, Some(300));
let unknown = ProcWidgetData::from_data(&ProcessHarvest::default(), false, false);
data.add(&unknown);
assert_eq!(data.swap_bytes, Some(300));
let known = ProcWidgetData::from_data(&process, false, false);
let mut unknown = ProcWidgetData::from_data(&ProcessHarvest::default(), false, false);
unknown.add(&known);
assert_eq!(unknown.swap_bytes, Some(100));
}
}