use crate::backend::{GpuBackend, GpuSnapshot, ProcKind};
use crate::keys::Action;
use crate::theme::UiTheme;
use std::collections::{HashMap, HashSet};
use std::time::{Duration, Instant};
use sysinfo::{
MINIMUM_CPU_UPDATE_INTERVAL, Pid, ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind,
Users,
};
const SPLASH_MS: u64 = 1500;
const STATUS_MS: u64 = 4000;
pub const MIN_TICK_MS: u64 = 50;
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Focus {
Gpus,
Procs,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, clap::ValueEnum)]
pub enum GraphStyle {
Braille,
Block,
Ascii,
}
impl GraphStyle {
pub fn from_config(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"braille" => Some(Self::Braille),
"block" => Some(Self::Block),
"ascii" => Some(Self::Ascii),
_ => None,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum InputMode {
Normal,
Filter,
Confirm,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
pub enum SortBy {
#[default]
GpuMem,
GpuUtil,
Cpu,
HostMem,
Pid,
}
impl SortBy {
pub fn next(self) -> Self {
match self {
SortBy::GpuMem => SortBy::GpuUtil,
SortBy::GpuUtil => SortBy::Cpu,
SortBy::Cpu => SortBy::HostMem,
SortBy::HostMem => SortBy::Pid,
SortBy::Pid => SortBy::GpuMem,
}
}
pub fn label(self) -> &'static str {
match self {
SortBy::GpuMem => "gpu-mem",
SortBy::GpuUtil => "gpu%",
SortBy::Cpu => "cpu%",
SortBy::HostMem => "host-mem",
SortBy::Pid => "pid",
}
}
}
#[derive(Default)]
pub struct History {
pub util: Vec<u64>,
pub vram: Vec<u64>,
pub power: Vec<u64>,
pub temp: Vec<u64>,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum DeviceKey {
Id(String),
Pos(usize),
}
fn device_keys(gpus: &[GpuSnapshot]) -> Vec<DeviceKey> {
let mut seen: HashSet<&str> = HashSet::new();
gpus.iter()
.enumerate()
.map(|(i, g)| match g.device_id.as_deref() {
Some(id) if seen.insert(id) => DeviceKey::Id(id.to_string()),
_ => DeviceKey::Pos(i),
})
.collect()
}
const MAX_ABSENT_DEVICES: usize = 16;
const MAX_FOLDS_PERSISTED: usize = 32;
fn command_of(p: &sysinfo::Process) -> String {
let cmd = p
.cmd()
.iter()
.map(|a| a.to_string_lossy())
.collect::<Vec<_>>()
.join(" ");
if cmd.trim().is_empty() {
p.name().to_string_lossy().into_owned()
} else {
cmd
}
}
fn cpu_sample_due(last: Option<Instant>, now: Instant) -> bool {
last.is_none_or(|last| now.duration_since(last) >= MINIMUM_CPU_UPDATE_INTERVAL)
}
#[derive(Default)]
struct Mean {
sum: f64,
n: u64,
}
impl Mean {
fn add(&mut self, v: f64) {
self.sum += v;
self.n += 1;
}
fn get(&self) -> Option<f64> {
(self.n > 0).then(|| self.sum / self.n as f64)
}
}
fn peak(slot: &mut Option<f64>, v: f64) {
*slot = Some(slot.map_or(v, |m| m.max(v)));
}
#[derive(Default)]
pub struct SessionStats {
pub max_util_pct: Option<f64>,
pub max_temp_c: Option<f64>,
pub max_power_w: Option<f64>,
util: Mean,
power: Mean,
}
impl SessionStats {
pub(crate) fn add(&mut self, g: &GpuSnapshot) {
if let Some(u) = g.utilization_pct {
peak(&mut self.max_util_pct, u);
self.util.add(u);
}
if let Some(t) = g.temperature_c {
peak(&mut self.max_temp_c, t);
}
if let Some(w) = g.power_w {
peak(&mut self.max_power_w, w);
self.power.add(w);
}
}
pub fn avg_util_pct(&self) -> Option<f64> {
self.util.get()
}
pub fn avg_power_w(&self) -> Option<f64> {
self.power.get()
}
}
#[cfg(any(target_os = "linux", test))]
fn container_of_cgroup(text: &str) -> Option<String> {
for line in text.lines() {
let path = line.rsplit(':').next().unwrap_or("");
for (marker, runtime) in [
("docker-", "docker"),
("libpod-", "podman"),
("cri-containerd-", "k8s"),
("crio-", "k8s"),
] {
if let Some(rest) = path.split('/').find_map(|seg| seg.strip_prefix(marker)) {
let id: String = rest
.chars()
.take_while(|c| c.is_ascii_hexdigit())
.take(12)
.collect();
if id.len() == 12 {
return Some(format!("{runtime}:{id}"));
}
}
}
if let Some(idx) = path.find("/docker/") {
let id: String = path[idx + 8..]
.chars()
.take_while(|c| c.is_ascii_hexdigit())
.take(12)
.collect();
if id.len() == 12 {
return Some(format!("docker:{id}"));
}
}
}
None
}
#[cfg(target_os = "linux")]
fn container_of_pid(pid: u32) -> Option<String> {
let text = std::fs::read_to_string(format!("/proc/{pid}/cgroup")).ok()?;
container_of_cgroup(&text)
}
#[cfg(not(target_os = "linux"))]
fn container_of_pid(_pid: u32) -> Option<String> {
None
}
#[derive(Clone, serde::Serialize)]
pub struct ProcRow {
pub pid: u32,
pub gpu_index: usize,
pub kind: ProcKind,
pub gpu_util_pct: Option<f64>,
pub gpu_mem_bytes: u64,
pub user: String,
pub cpu_pct: f32,
pub host_mem_bytes: u64,
pub command: String,
pub container: Option<String>,
}
pub struct PendingKill {
pub pid: u32,
pub start_time: u64,
pub force: bool,
pub command: String,
}
#[derive(serde::Serialize, serde::Deserialize, Default)]
pub struct UiState {
#[serde(default)]
pub folded_devices: Vec<String>,
pub sort_by: SortBy,
pub sort_desc: bool,
pub tick_ms: u64,
#[serde(default)]
pub tick_ms_explicit: Option<bool>,
}
impl UiState {
pub fn sticky_tick_ms(&self) -> Option<u64> {
(self.tick_ms_explicit != Some(false) && self.tick_ms > 0).then_some(self.tick_ms)
}
pub fn tick_chosen_by_key(&self) -> bool {
self.tick_ms_explicit == Some(true)
}
}
fn state_path() -> Option<std::path::PathBuf> {
hjkl_config::cache_dir("gpur")
.ok()
.map(|d| d.join("state.json"))
}
pub fn load_state() -> Option<UiState> {
read_state(&state_path()?)
}
fn read_state(path: &std::path::Path) -> Option<UiState> {
let text = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&text).ok()
}
fn state_temp_path(path: &std::path::Path) -> std::path::PathBuf {
let mut name = path.file_name().unwrap_or_default().to_os_string();
name.push(format!(".{}.tmp", std::process::id()));
path.with_file_name(name)
}
fn write_state(path: &std::path::Path, state: &UiState) {
let Ok(json) = serde_json::to_string_pretty(state) else {
return;
};
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let tmp = state_temp_path(path);
if write_private(&tmp, json.as_bytes()).is_err() || std::fs::rename(&tmp, path).is_err() {
let _ = std::fs::remove_file(&tmp);
}
}
fn write_private(path: &std::path::Path, contents: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
opts.open(path)?.write_all(contents)
}
pub struct AppOptions {
pub tick_ms: u64,
pub tick_explicit: bool,
pub history_len: usize,
pub no_splash: bool,
pub graph_style: GraphStyle,
pub source: crate::backend::BackendSource,
pub log: Option<std::io::BufWriter<std::fs::File>>,
}
pub struct App {
pub backend: Box<dyn GpuBackend>,
pub gpus: Vec<GpuSnapshot>,
keys: Vec<DeviceKey>,
history: HashMap<DeviceKey, History>,
session: HashMap<DeviceKey, SessionStats>,
last_seen: HashMap<DeviceKey, u64>,
polls: u64,
pub history_len: usize,
pub selected: usize,
pub paused: bool,
pub tick_ms: u64,
pub theme: UiTheme,
pub started: Instant,
pub splash_path: Vec<(u8, u8, char)>,
pub splash_skipped: bool,
pub procs: Vec<ProcRow>,
folded: HashSet<DeviceKey>,
pub gpu_scroll: usize,
pub proc_scroll: usize,
pub proc_sel: usize,
pub proc_visible: usize,
pub gpus_rect: ratatui::layout::Rect,
pub proc_rect: ratatui::layout::Rect,
pub focus: Focus,
pub poll_error: Option<String>,
pub input_mode: InputMode,
pub filter: String,
pub filter_input: String,
pub sort_by: SortBy,
pub sort_desc: bool,
pub pending_kill: Option<PendingKill>,
pub status: Option<(String, Instant)>,
pub show_help: bool,
pub card_rects: Vec<(ratatui::layout::Rect, usize)>,
pub history_need: usize,
pub graph_style: GraphStyle,
source: crate::backend::BackendSource,
poll_failures: u32,
log: Option<std::io::BufWriter<std::fs::File>>,
pub all_procs: Vec<ProcRow>,
tick_explicit: bool,
sys: System,
last_cpu_sample: Option<Instant>,
users: Users,
}
impl App {
pub fn new(backend: Box<dyn GpuBackend>, theme: UiTheme, opts: AppOptions) -> Self {
let AppOptions {
tick_ms,
tick_explicit,
history_len,
no_splash,
graph_style,
source,
log,
} = opts;
Self {
tick_explicit,
graph_style,
source,
poll_failures: 0,
log,
backend,
gpus: Vec::new(),
keys: Vec::new(),
history: HashMap::new(),
session: HashMap::new(),
last_seen: HashMap::new(),
polls: 0,
history_len,
selected: 0,
paused: false,
tick_ms,
theme,
started: Instant::now(),
splash_path: crate::splash::build_path(),
splash_skipped: no_splash,
procs: Vec::new(),
folded: HashSet::new(),
gpu_scroll: 0,
proc_scroll: 0,
proc_sel: 0,
proc_visible: 0,
gpus_rect: ratatui::layout::Rect::default(),
proc_rect: ratatui::layout::Rect::default(),
focus: Focus::Gpus,
poll_error: None,
input_mode: InputMode::Normal,
filter: String::new(),
filter_input: String::new(),
sort_by: SortBy::GpuMem,
sort_desc: true,
pending_kill: None,
status: None,
show_help: false,
card_rects: Vec::new(),
history_need: 0,
all_procs: Vec::new(),
sys: System::new(),
last_cpu_sample: None,
users: Users::new_with_refreshed_list(),
}
}
pub fn restore_state(&mut self, s: &UiState) {
self.folded = s
.folded_devices
.iter()
.cloned()
.map(DeviceKey::Id)
.collect();
self.sort_by = s.sort_by;
self.sort_desc = s.sort_desc;
}
pub fn save_state(&self) {
let Some(path) = state_path() else { return };
let state = UiState {
folded_devices: self.persisted_folds(),
sort_by: self.sort_by,
sort_desc: self.sort_desc,
tick_ms: self.tick_ms,
tick_ms_explicit: Some(self.tick_explicit),
};
write_state(&path, &state);
}
fn persisted_folds(&self) -> Vec<String> {
let (mut seen, mut gone): (Vec<String>, Vec<String>) = self
.folded
.iter()
.filter_map(|k| match k {
DeviceKey::Id(id) => Some(id.clone()),
DeviceKey::Pos(_) => None,
})
.partition(|id| self.last_seen.contains_key(&DeviceKey::Id(id.clone())));
seen.sort();
gone.sort();
seen.into_iter()
.chain(gone)
.take(MAX_FOLDS_PERSISTED)
.collect()
}
fn evict_absent_devices(&mut self) {
let live = self.keys.len();
let Some(excess) = self.last_seen.len().checked_sub(live + MAX_ABSENT_DEVICES) else {
return;
};
let mut absent: Vec<(u64, DeviceKey)> = self
.last_seen
.iter()
.filter(|(k, _)| !self.keys.contains(k))
.map(|(k, seen)| (*seen, k.clone()))
.collect();
absent.sort_unstable();
for (_, k) in absent.into_iter().take(excess) {
self.history.remove(&k);
self.session.remove(&k);
self.last_seen.remove(&k);
self.folded.remove(&k);
}
}
pub fn history_at(&self, idx: usize) -> Option<&History> {
self.history.get(self.keys.get(idx)?)
}
pub fn session_at(&self, idx: usize) -> Option<&SessionStats> {
self.session.get(self.keys.get(idx)?)
}
pub fn is_folded(&self, idx: usize) -> bool {
self.keys.get(idx).is_some_and(|k| self.folded.contains(k))
}
fn toggle_fold(&mut self, idx: usize) {
let Some(key) = self.keys.get(idx).cloned() else {
return;
};
if !self.folded.remove(&key) {
self.folded.insert(key);
}
}
pub fn splash_active(&self) -> bool {
!self.splash_skipped && self.started.elapsed() < Duration::from_millis(SPLASH_MS)
}
pub fn status_line(&self) -> Option<&str> {
match &self.status {
Some((msg, at)) if at.elapsed() < Duration::from_millis(STATUS_MS) => {
Some(msg.as_str())
}
_ => None,
}
}
fn set_status(&mut self, msg: String) {
self.status = Some((msg, Instant::now()));
}
pub fn poll(&mut self) {
self.poll_inner(true);
}
pub fn poll_priming(&mut self) {
self.poll_inner(false);
}
fn poll_inner(&mut self, log: bool) {
if self.paused {
return;
}
match self.backend.poll() {
Ok(gpus) => {
self.gpus = gpus;
self.poll_error = None;
self.poll_failures = 0;
}
Err(e) => {
self.poll_error = Some(format!("poll failed: {e:#}"));
self.poll_failures += 1;
if self.poll_failures.is_multiple_of(5)
&& let Ok(fresh) = self.source.detect()
{
self.backend = fresh;
self.set_status(format!(
"backend re-detected ({}) after {} failed polls",
self.backend.name(),
self.poll_failures
));
}
return; }
}
let selected_key = self.keys.get(self.selected).cloned();
self.keys = device_keys(&self.gpus);
self.selected = selected_key
.and_then(|k| self.keys.iter().position(|x| *x == k))
.unwrap_or_else(|| self.selected.min(self.gpus.len().saturating_sub(1)));
self.polls += 1;
let Self {
gpus,
keys,
history,
session,
last_seen,
polls,
history_len,
history_need,
..
} = self;
let cap = (*history_len).max(*history_need + 8);
for (gpu, key) in gpus.iter().zip(keys.iter()) {
last_seen.insert(key.clone(), *polls);
session.entry(key.clone()).or_default().add(gpu);
let hist = history.entry(key.clone()).or_default();
hist.util
.push(gpu.utilization_pct.unwrap_or(0.0).round() as u64);
hist.vram.push(gpu.vram_pct().unwrap_or(0.0).round() as u64);
hist.power.push(gpu.power_w.unwrap_or(0.0).round() as u64);
hist.temp
.push(gpu.temperature_c.unwrap_or(0.0).round() as u64);
let overflow = hist.util.len().saturating_sub(cap);
if overflow > 0 {
hist.util.drain(..overflow);
hist.vram.drain(..overflow);
hist.power.drain(..overflow);
hist.temp.drain(..overflow);
}
}
self.evict_absent_devices();
self.refresh_processes();
if log {
self.write_log();
}
}
pub fn record(&self) -> serde_json::Value {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
serde_json::json!({
"ts_ms": ts,
"backend": self.backend.name(),
"driver": self.backend.driver_info(),
"gpus": self.gpus,
"processes": self.all_procs,
})
}
fn write_log(&mut self) {
use std::io::Write;
if self.log.is_none() {
return;
}
let rec = self.record();
let Some(w) = self.log.as_mut() else { return };
let ok = serde_json::to_writer(&mut *w, &rec).is_ok()
&& writeln!(w).is_ok()
&& w.flush().is_ok();
if !ok {
self.log = None;
self.set_status("log write failed — logging disabled".into());
}
}
fn refresh_processes(&mut self) {
let gpu_procs = self.backend.processes();
let mut pids: Vec<Pid> = gpu_procs.iter().map(|p| Pid::from_u32(p.pid)).collect();
pids.sort_unstable();
pids.dedup();
let now = Instant::now();
let sample_cpu = cpu_sample_due(self.last_cpu_sample, now);
if sample_cpu {
self.last_cpu_sample = Some(now);
}
let kind = ProcessRefreshKind::nothing()
.with_memory()
.with_user(UpdateKind::OnlyIfNotSet)
.with_cmd(UpdateKind::OnlyIfNotSet);
let kind = if sample_cpu { kind.with_cpu() } else { kind };
self.sys
.refresh_processes_specifics(ProcessesToUpdate::Some(&pids), true, kind);
self.evict_departed_processes(&pids);
self.all_procs = gpu_procs
.into_iter()
.map(|gp| {
let p = self.sys.process(Pid::from_u32(gp.pid));
ProcRow {
user: gp
.user
.clone()
.or_else(|| {
p.and_then(|p| p.user_id())
.and_then(|uid| self.users.get_user_by_id(uid))
.map(|u| u.name().to_string())
})
.unwrap_or_else(|| "-".into()),
cpu_pct: gp.cpu_pct.or(p.map(|p| p.cpu_usage())).unwrap_or(0.0),
host_mem_bytes: gp.host_mem_bytes.or(p.map(|p| p.memory())).unwrap_or(0),
command: gp
.command
.clone()
.or(p.map(command_of))
.unwrap_or_else(|| "?".into()),
container: gp.container.clone().or_else(|| container_of_pid(gp.pid)),
pid: gp.pid,
gpu_index: gp.gpu_index,
kind: gp.kind,
gpu_util_pct: gp.gpu_util_pct,
gpu_mem_bytes: gp.gpu_mem_bytes,
}
})
.collect();
self.all_procs.sort_by(|a, b| {
b.gpu_mem_bytes
.cmp(&a.gpu_mem_bytes)
.then(a.pid.cmp(&b.pid))
.then(a.gpu_index.cmp(&b.gpu_index))
});
self.rebuild_proc_view();
}
fn evict_departed_processes(&mut self, live: &[Pid]) {
let live: HashSet<Pid> = live.iter().copied().collect();
let departed: Vec<Pid> = self
.sys
.processes()
.keys()
.copied()
.filter(|pid| !live.contains(pid))
.collect();
if departed.is_empty() {
return;
}
self.sys.refresh_processes_specifics(
ProcessesToUpdate::Some(&departed),
true,
ProcessRefreshKind::nothing(),
);
}
pub fn rebuild_proc_view(&mut self) {
let cursor_key = self.procs.get(self.proc_sel).map(|p| (p.pid, p.gpu_index));
let needle = self.filter.to_lowercase();
let mut rows: Vec<ProcRow> = self
.all_procs
.iter()
.filter(|p| {
needle.is_empty()
|| p.command.to_lowercase().contains(&needle)
|| p.user.to_lowercase().contains(&needle)
|| p.pid.to_string().contains(&needle)
|| p.container
.as_deref()
.is_some_and(|c| c.to_lowercase().contains(&needle))
})
.cloned()
.collect();
rows.sort_by(|a, b| {
if self.sort_by == SortBy::GpuUtil {
match (a.gpu_util_pct.is_none(), b.gpu_util_pct.is_none()) {
(false, true) => return std::cmp::Ordering::Less,
(true, false) => return std::cmp::Ordering::Greater,
_ => {}
}
}
let ord = match self.sort_by {
SortBy::GpuMem => a.gpu_mem_bytes.cmp(&b.gpu_mem_bytes),
SortBy::GpuUtil => a
.gpu_util_pct
.unwrap_or(0.0)
.total_cmp(&b.gpu_util_pct.unwrap_or(0.0)),
SortBy::Cpu => a.cpu_pct.total_cmp(&b.cpu_pct),
SortBy::HostMem => a.host_mem_bytes.cmp(&b.host_mem_bytes),
SortBy::Pid => a.pid.cmp(&b.pid),
};
let ord = if self.sort_desc { ord.reverse() } else { ord };
ord.then(a.pid.cmp(&b.pid))
});
self.procs = rows;
self.proc_sel = cursor_key
.and_then(|key| self.procs.iter().position(|p| (p.pid, p.gpu_index) == key))
.unwrap_or_else(|| self.proc_sel.min(self.procs.len().saturating_sub(1)));
}
pub fn filter_delete_word(&mut self) {
while self.filter_input.ends_with(char::is_whitespace) {
self.filter_input.pop();
}
while !self.filter_input.is_empty() && !self.filter_input.ends_with(char::is_whitespace) {
self.filter_input.pop();
}
}
pub fn commit_filter(&mut self) {
self.filter = self.filter_input.trim().to_string();
self.input_mode = InputMode::Normal;
self.rebuild_proc_view();
}
pub fn can_signal(&self) -> bool {
self.backend.can_signal()
}
fn request_kill(&mut self, force: bool) {
if !self.can_signal() {
self.set_status(format!(
"kill disabled: {} pids don't name processes on this machine",
self.backend.name()
));
return;
}
if self.focus != Focus::Procs {
self.set_status("kill: focus the process pane first (p)".into());
return;
}
let Some(row) = self.procs.get(self.proc_sel) else {
return;
};
let pid = row.pid;
let command: String = row.command.chars().take(40).collect();
let Some(start_time) = self.sys.process(Pid::from_u32(pid)).map(|p| p.start_time()) else {
self.set_status(format!("kill: pid {pid} is not a live local process"));
return;
};
self.pending_kill = Some(PendingKill {
pid,
start_time,
force,
command,
});
self.input_mode = InputMode::Confirm;
}
pub fn confirm_kill(&mut self) {
let Some(k) = self.pending_kill.take() else {
return;
};
self.input_mode = InputMode::Normal;
let PendingKill {
pid,
start_time,
force,
command,
} = k;
let sig_name = if force { "SIGKILL" } else { "SIGTERM" };
if !self.can_signal() {
self.set_status(format!(
"kill: {} pids are not signalable",
self.backend.name()
));
return;
}
if pid == 1 {
self.set_status("kill: refusing to signal pid 1 (init)".into());
return;
}
if pid == std::process::id() {
self.set_status("kill: refusing to signal gpur itself".into());
return;
}
let target = Pid::from_u32(pid);
self.sys.refresh_processes_specifics(
ProcessesToUpdate::Some(&[target]),
true,
ProcessRefreshKind::nothing().with_exe(UpdateKind::Always),
);
let Some(p) = self.sys.process(target) else {
self.set_status(format!("kill: pid {pid} no longer exists"));
return;
};
if p.start_time() != start_time {
self.set_status(format!("kill: pid {pid} was reused by another process"));
return;
}
if p.exe().is_none() {
self.set_status(format!(
"kill: pid {pid} has no executable (kernel thread?) — refusing"
));
return;
}
let sig = if force {
sysinfo::Signal::Kill
} else {
sysinfo::Signal::Term
};
let Some(ok) = p.kill_with(sig) else {
let hint = if force {
""
} else {
" — use K to send SIGKILL explicitly"
};
self.set_status(format!(
"kill: {sig_name} is unsupported on this platform{hint}"
));
return;
};
if ok {
self.set_status(format!("sent {sig_name} to {pid} ({command})"));
} else {
self.set_status(format!(
"{sig_name} to {pid} failed (permission? try as root)"
));
}
}
pub fn apply(&mut self, action: Action) -> bool {
match action {
Action::Quit => return true,
Action::TogglePause => self.paused = !self.paused,
Action::NextItem => match self.focus {
Focus::Gpus => self.next_gpu(),
Focus::Procs => self.proc_down(),
},
Action::PrevItem => match self.focus {
Focus::Gpus => self.prev_gpu(),
Focus::Procs => self.proc_up(),
},
Action::NextGpu => self.next_gpu(),
Action::PrevGpu => self.prev_gpu(),
Action::TickFaster => {
self.tick_ms = (self.tick_ms / 2).max(MIN_TICK_MS);
self.tick_explicit = true;
}
Action::TickSlower => {
self.tick_ms = (self.tick_ms * 2).min(10_000);
self.tick_explicit = true;
}
Action::Digit(i) => {
if i < self.gpus.len() {
if self.focus == Focus::Gpus && self.selected == i {
self.toggle_fold(i);
} else {
self.focus = Focus::Gpus;
self.selected = i;
}
}
}
Action::FocusProcs => self.focus = Focus::Procs,
Action::Help => self.show_help = true,
Action::ProcScrollDown => self.proc_down(),
Action::ProcScrollUp => self.proc_up(),
Action::SortCycle => {
self.sort_by = self.sort_by.next();
self.rebuild_proc_view();
}
Action::SortReverse => {
self.sort_desc = !self.sort_desc;
self.rebuild_proc_view();
}
Action::FilterOpen => {
self.focus = Focus::Procs;
self.filter_input = self.filter.clone();
self.input_mode = InputMode::Filter;
}
Action::KillTerm | Action::KillForce => {
self.request_kill(matches!(action, Action::KillForce))
}
}
false
}
fn proc_down(&mut self) {
self.proc_sel = (self.proc_sel + 1).min(self.procs.len().saturating_sub(1));
}
fn proc_up(&mut self) {
self.proc_sel = self.proc_sel.saturating_sub(1);
}
fn next_gpu(&mut self) {
self.selected = (self.selected + 1).min(self.gpus.len().saturating_sub(1));
}
fn prev_gpu(&mut self) {
self.selected = self.selected.saturating_sub(1);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::GpuBackend;
struct LocalBackend;
impl GpuBackend for LocalBackend {
fn name(&self) -> &'static str {
"test"
}
fn poll(&mut self) -> anyhow::Result<Vec<GpuSnapshot>> {
Ok(Vec::new())
}
}
struct RecordedBackend;
impl GpuBackend for RecordedBackend {
fn name(&self) -> &'static str {
"recorded"
}
fn poll(&mut self) -> anyhow::Result<Vec<GpuSnapshot>> {
Ok(vec![GpuSnapshot::default()])
}
fn driver_info(&self) -> Option<String> {
Some("recorded driver 1.2".into())
}
fn processes(&mut self) -> Vec<crate::backend::GpuProcess> {
vec![
crate::backend::GpuProcess {
pid: 1,
gpu_mem_bytes: 1,
command: Some("idle".into()),
user: Some("root".into()),
..Default::default()
},
crate::backend::GpuProcess {
pid: 4242,
gpu_mem_bytes: 2 << 30,
command: Some("train.py".into()),
user: Some("bob".into()),
container: Some("docker:abcdef123456".into()),
..Default::default()
},
]
}
}
#[cfg(unix)]
struct ChurningBackend {
ticks: Vec<Vec<u32>>,
tick: usize,
}
#[cfg(unix)]
impl GpuBackend for ChurningBackend {
fn name(&self) -> &'static str {
"churning"
}
fn poll(&mut self) -> anyhow::Result<Vec<GpuSnapshot>> {
Ok(Vec::new())
}
fn processes(&mut self) -> Vec<crate::backend::GpuProcess> {
let i = self.tick.min(self.ticks.len() - 1);
self.tick += 1;
self.ticks[i]
.iter()
.map(|pid| crate::backend::GpuProcess {
pid: *pid,
..Default::default()
})
.collect()
}
}
struct ScriptedBackend {
ticks: Vec<Vec<(Option<&'static str>, f64)>>,
tick: usize,
}
impl ScriptedBackend {
fn boxed(ticks: Vec<Vec<(Option<&'static str>, f64)>>) -> Box<dyn GpuBackend> {
Box::new(Self { ticks, tick: 0 })
}
}
impl GpuBackend for ScriptedBackend {
fn name(&self) -> &'static str {
"scripted"
}
fn poll(&mut self) -> anyhow::Result<Vec<GpuSnapshot>> {
let i = self.tick.min(self.ticks.len() - 1);
self.tick += 1;
Ok(self.ticks[i]
.iter()
.map(|(id, util)| GpuSnapshot {
name: id.unwrap_or("anon").to_string(),
device_id: id.map(str::to_string),
utilization_pct: Some(*util),
..Default::default()
})
.collect())
}
}
fn util_history(app: &App, idx: usize) -> Vec<u64> {
app.history_at(idx).expect("history for card").util.clone()
}
struct Scratch(std::path::PathBuf);
impl Scratch {
fn new(tag: &str) -> Self {
use std::sync::atomic::{AtomicU32, Ordering};
static N: AtomicU32 = AtomicU32::new(0);
let dir = std::env::temp_dir().join(format!(
"gpur-app-{tag}-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&dir).unwrap();
Scratch(dir)
}
fn replay_log(tag: &str, body: &str) -> Self {
let scratch = Self::new(tag);
std::fs::write(scratch.path(), body).unwrap();
scratch
}
fn join(&self, name: &str) -> std::path::PathBuf {
self.0.join(name)
}
fn path(&self) -> std::path::PathBuf {
self.join("rec.jsonl")
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn app_with(backend: Box<dyn GpuBackend>) -> App {
app_from(backend, crate::backend::BackendSource::Live)
}
fn app_from(backend: Box<dyn GpuBackend>, source: crate::backend::BackendSource) -> App {
App::new(
backend,
crate::theme::load(None, crate::theme::detect_color_mode()).unwrap(),
AppOptions {
tick_ms: 1000,
tick_explicit: false,
history_len: 60,
no_splash: true,
graph_style: GraphStyle::Ascii,
source,
log: None,
},
)
}
fn arm(app: &mut App, pid: u32, start_time: u64) {
app.pending_kill = Some(PendingKill {
pid,
start_time,
force: false,
command: "victim".into(),
});
app.input_mode = InputMode::Confirm;
}
fn status_of(app: &App) -> String {
app.status_line().unwrap_or("").to_string()
}
#[test]
fn fabricated_backends_refuse_to_signal() {
assert!(!crate::backend::detect(Some(2), None).unwrap().can_signal());
assert!(LocalBackend.can_signal());
let scratch = Scratch::replay_log("can-signal", "{\"gpus\":[],\"processes\":[]}\n");
let replay = crate::backend::detect(None, Some(&scratch.path())).unwrap();
assert_eq!(replay.name(), "replay");
assert!(!replay.can_signal());
}
#[test]
fn a_failing_replay_re_detects_to_a_replay_not_to_live_hardware() {
struct DeadRecording;
impl GpuBackend for DeadRecording {
fn name(&self) -> &'static str {
"recording"
}
fn poll(&mut self) -> anyhow::Result<Vec<GpuSnapshot>> {
anyhow::bail!("the log went away")
}
fn can_signal(&self) -> bool {
false
}
}
let scratch = Scratch::replay_log("re-detect", "{\"gpus\":[],\"processes\":[]}\n");
let mut app = app_from(
Box::new(DeadRecording),
crate::backend::BackendSource::Replay(scratch.path()),
);
for _ in 0..12 {
app.poll();
}
assert_eq!(
app.backend.name(),
"replay",
"a re-detect swapped a recording for something else"
);
assert!(!app.can_signal(), "a re-detect made a recording signalable");
arm(&mut app, 424242, 0);
app.confirm_kill();
assert!(
status_of(&app).contains("not signalable"),
"{}",
status_of(&app)
);
}
#[test]
fn kill_refuses_init_and_self() {
let mut app = app_with(Box::new(LocalBackend));
arm(&mut app, 1, 0);
app.confirm_kill();
assert!(status_of(&app).contains("pid 1"), "{}", status_of(&app));
assert!(app.pending_kill.is_none());
assert!(app.input_mode == InputMode::Normal);
arm(&mut app, std::process::id(), 0);
app.confirm_kill();
assert!(
status_of(&app).contains("gpur itself"),
"{}",
status_of(&app)
);
}
#[test]
fn kill_refuses_when_the_backend_is_not_local() {
let mut app = app_with(crate::backend::detect(Some(1), None).unwrap());
arm(&mut app, 424242, 0);
app.confirm_kill();
assert!(
status_of(&app).contains("not signalable"),
"{}",
status_of(&app)
);
}
#[test]
fn kill_refuses_a_pid_that_no_longer_exists() {
let mut app = app_with(Box::new(LocalBackend));
arm(&mut app, 4_194_300, 0);
app.confirm_kill();
assert!(
status_of(&app).contains("no longer exists"),
"{}",
status_of(&app)
);
}
#[test]
#[cfg(unix)]
fn kill_refuses_a_recycled_pid() {
let mut child = std::process::Command::new("sleep")
.arg("30")
.spawn()
.expect("spawn sleep");
let pid = Pid::from_u32(child.id());
let mut app = app_with(Box::new(LocalBackend));
app.sys
.refresh_processes(ProcessesToUpdate::Some(&[pid]), true);
let real = app.sys.process(pid).expect("child visible").start_time();
arm(&mut app, child.id(), real + 1);
app.confirm_kill();
assert!(
status_of(&app).contains("reused by another process"),
"{}",
status_of(&app)
);
assert!(
matches!(child.try_wait(), Ok(None)),
"child was signalled despite the start-time mismatch"
);
arm(&mut app, child.id(), real);
app.confirm_kill();
assert!(
status_of(&app).contains("sent SIGTERM"),
"{}",
status_of(&app)
);
let _ = child.wait();
}
#[test]
fn kill_needs_the_process_pane_focused() {
let mut app = app_with(Box::new(LocalBackend));
app.all_procs = vec![ProcRow {
pid: std::process::id(),
gpu_index: 0,
kind: ProcKind::Compute,
gpu_util_pct: None,
gpu_mem_bytes: 0,
user: "me".into(),
cpu_pct: 0.0,
host_mem_bytes: 0,
command: "gpur".into(),
container: None,
}];
app.rebuild_proc_view();
app.focus = Focus::Gpus;
app.apply(Action::KillTerm);
assert!(app.pending_kill.is_none());
assert!(
status_of(&app).contains("focus the process pane"),
"{}",
status_of(&app)
);
app.focus = Focus::Procs;
app.sys.refresh_processes(
ProcessesToUpdate::Some(&[Pid::from_u32(std::process::id())]),
true,
);
app.apply(Action::KillTerm);
assert!(app.pending_kill.is_some());
assert!(app.input_mode == InputMode::Confirm);
}
#[test]
#[cfg(unix)]
fn a_pid_that_leaves_the_gpu_table_is_evicted_from_the_process_cache() {
let mut child = std::process::Command::new("sleep")
.arg("30")
.spawn()
.expect("spawn sleep");
let pid = child.id();
let mut app = app_with(Box::new(ChurningBackend {
ticks: vec![vec![pid], vec![]],
tick: 0,
}));
app.poll();
assert!(
app.sys.process(Pid::from_u32(pid)).is_some(),
"the child never reached the cache, so the eviction would prove nothing"
);
child.kill().expect("kill child");
child.wait().expect("reap child");
app.poll();
assert!(
app.sys.process(Pid::from_u32(pid)).is_none(),
"a departed pid accumulated in the sysinfo cache"
);
}
#[test]
fn a_cpu_sample_is_due_only_once_sysinfos_minimum_interval_has_elapsed() {
let last = Instant::now();
assert!(
cpu_sample_due(None, last),
"the first poll has no previous sample to be too close to"
);
assert!(
!cpu_sample_due(Some(last), last + Duration::from_millis(MIN_TICK_MS)),
"a poll at the tick floor was taken as a CPU sample"
);
assert!(
!cpu_sample_due(
Some(last),
last + MINIMUM_CPU_UPDATE_INTERVAL - Duration::from_millis(1)
),
"a sample one millisecond short of the interval still counted"
);
assert!(
cpu_sample_due(Some(last), last + MINIMUM_CPU_UPDATE_INTERVAL),
"the documented minimum interval must be enough"
);
}
#[test]
fn tick_faster_stops_at_the_shared_floor() {
let mut app = app_with(Box::new(LocalBackend));
app.tick_ms = MIN_TICK_MS;
app.apply(Action::TickFaster);
assert_eq!(app.tick_ms, MIN_TICK_MS);
app.tick_ms = MIN_TICK_MS * 2;
app.apply(Action::TickFaster);
assert_eq!(app.tick_ms, MIN_TICK_MS);
assert!(app.tick_explicit);
}
#[test]
fn tick_stays_non_sticky_until_a_key_changes_it() {
let app = app_with(Box::new(LocalBackend));
assert!(!app.tick_explicit);
}
#[test]
fn legacy_tick_state_is_honoured_once_then_demoted() {
let legacy: UiState = serde_json::from_str(
r#"{"folded":[],"sort_by":"Pid","sort_desc":false,"tick_ms":300}"#,
)
.unwrap();
assert_eq!(legacy.sticky_tick_ms(), Some(300));
assert!(!legacy.tick_chosen_by_key());
let chosen: UiState = serde_json::from_str(
r#"{"folded":[],"sort_by":"Pid","sort_desc":false,"tick_ms":300,"tick_ms_explicit":true}"#,
)
.unwrap();
assert_eq!(chosen.sticky_tick_ms(), Some(300));
assert!(chosen.tick_chosen_by_key());
let config_only: UiState = serde_json::from_str(
r#"{"folded":[],"sort_by":"Pid","sort_desc":false,"tick_ms":300,"tick_ms_explicit":false}"#,
)
.unwrap();
assert_eq!(config_only.sticky_tick_ms(), None);
}
#[test]
fn filter_delete_word_removes_one_word_and_its_blanks() {
let mut app = app_with(Box::new(LocalBackend));
app.filter_input = "python train.py ".into();
app.filter_delete_word();
assert_eq!(app.filter_input, "python ");
app.filter_delete_word();
assert_eq!(app.filter_input, "");
app.filter_delete_word();
assert_eq!(app.filter_input, "");
}
#[test]
fn recorded_rows_win_over_local_resolution() {
let mut app = app_with(Box::new(RecordedBackend));
app.poll();
assert_eq!(app.all_procs.len(), 2);
assert_eq!(
app.all_procs[0].container.as_deref(),
Some("docker:abcdef123456")
);
assert_eq!(app.all_procs[0].command, "train.py");
}
#[test]
fn record_carries_attribution_and_stable_unfiltered_rows() {
let mut app = app_with(Box::new(RecordedBackend));
app.poll();
app.filter = "train".into();
app.sort_by = SortBy::Pid;
app.sort_desc = false;
app.rebuild_proc_view();
assert_eq!(app.procs.len(), 1, "filter should narrow the UI view");
let rec = app.record();
assert_eq!(rec["backend"], "recorded");
assert_eq!(rec["driver"], "recorded driver 1.2");
assert!(rec["ts_ms"].as_u64().unwrap() > 0);
assert_eq!(rec["gpus"].as_array().unwrap().len(), 1);
let procs = rec["processes"].as_array().unwrap();
assert_eq!(procs.len(), 2, "record must not inherit the UI filter");
assert_eq!(procs[0]["pid"], 4242);
assert_eq!(procs[0]["container"], "docker:abcdef123456");
}
#[test]
fn session_peaks_stay_unknown_without_a_sensor() {
let mut s = SessionStats::default();
for util in [10.0, 90.0, 40.0] {
s.add(&GpuSnapshot {
utilization_pct: Some(util),
..Default::default()
});
}
assert_eq!(s.max_util_pct, Some(90.0));
assert_eq!(s.avg_util_pct(), Some(140.0 / 3.0));
assert_eq!(s.max_temp_c, None);
assert_eq!(s.max_power_w, None);
assert_eq!(s.avg_power_w(), None);
let mut blind = SessionStats::default();
blind.add(&GpuSnapshot::default());
assert_eq!(blind.max_util_pct, None);
assert_eq!(blind.avg_util_pct(), None);
}
#[test]
fn averages_ignore_samples_with_no_reading() {
let mut s = SessionStats::default();
let sample = |w| GpuSnapshot {
utilization_pct: Some(50.0),
power_w: w,
..Default::default()
};
s.add(&sample(Some(100.0)));
s.add(&sample(None));
s.add(&sample(None));
s.add(&sample(Some(200.0)));
assert_eq!(s.avg_power_w(), Some(150.0));
assert_eq!(s.max_power_w, Some(200.0));
assert_eq!(s.avg_util_pct(), Some(50.0));
}
#[test]
fn rows_with_unknown_gpu_util_sink_in_both_directions() {
let mut app = app_with(Box::new(LocalBackend));
let row = |pid, util| ProcRow {
pid,
gpu_index: 0,
kind: ProcKind::Compute,
gpu_util_pct: util,
gpu_mem_bytes: 0,
user: "me".into(),
cpu_pct: 0.0,
host_mem_bytes: 0,
command: "x".into(),
container: None,
};
app.all_procs = vec![row(1, Some(10.0)), row(2, None), row(3, Some(90.0))];
app.sort_by = SortBy::GpuUtil;
app.sort_desc = true;
app.rebuild_proc_view();
assert_eq!(
app.procs.iter().map(|p| p.pid).collect::<Vec<_>>(),
vec![3, 1, 2]
);
app.sort_desc = false;
app.rebuild_proc_view();
assert_eq!(
app.procs.iter().map(|p| p.pid).collect::<Vec<_>>(),
vec![1, 3, 2],
"an unmeasured row sorted as if it were 0%"
);
}
#[test]
fn per_device_state_follows_the_device_across_a_reorder() {
let mut app = app_with(ScriptedBackend::boxed(vec![
vec![(Some("a"), 10.0), (Some("b"), 90.0)],
vec![(Some("b"), 20.0), (Some("a"), 30.0)],
]));
app.poll();
app.poll();
assert_eq!(app.gpus[0].name, "b");
assert_eq!(util_history(&app, 0), vec![90, 20]);
assert_eq!(app.session_at(0).unwrap().max_util_pct, Some(90.0));
assert_eq!(util_history(&app, 1), vec![10, 30]);
assert_eq!(app.session_at(1).unwrap().max_util_pct, Some(30.0));
}
#[test]
fn a_returning_device_is_re_identified_not_treated_as_new() {
let mut app = app_with(ScriptedBackend::boxed(vec![
vec![(Some("a"), 10.0), (Some("b"), 90.0)],
vec![(Some("b"), 20.0)],
vec![(Some("b"), 30.0), (Some("a"), 40.0)],
]));
app.poll();
app.poll();
app.poll();
assert_eq!(app.gpus[1].name, "a");
assert_eq!(util_history(&app, 1), vec![10, 40]);
assert_eq!(app.session_at(1).unwrap().max_util_pct, Some(40.0));
assert_eq!(util_history(&app, 0), vec![90, 20, 30]);
}
#[test]
fn the_selection_follows_the_selected_device() {
let mut app = app_with(ScriptedBackend::boxed(vec![
vec![(Some("a"), 1.0), (Some("b"), 2.0)],
vec![(Some("c"), 3.0), (Some("a"), 1.0), (Some("b"), 2.0)],
]));
app.poll();
app.selected = 1; app.poll();
assert_eq!(app.gpus[app.selected].name, "b");
let mut app = app_with(ScriptedBackend::boxed(vec![
vec![(Some("a"), 1.0), (Some("b"), 2.0)],
vec![(Some("a"), 1.0)],
]));
app.poll();
app.selected = 1;
app.poll();
assert_eq!(app.selected, 0);
}
#[test]
fn a_fold_survives_a_device_being_added_or_removed() {
let mut app = app_with(ScriptedBackend::boxed(vec![
vec![(Some("a"), 1.0), (Some("b"), 2.0)],
vec![(Some("c"), 3.0), (Some("a"), 1.0), (Some("b"), 2.0)],
vec![(Some("c"), 3.0), (Some("b"), 2.0)],
]));
app.poll();
app.selected = 1;
app.apply(Action::Digit(1)); assert!(app.is_folded(1));
app.poll();
assert!(app.is_folded(2), "the fold did not follow the device");
assert!(!app.is_folded(0) && !app.is_folded(1));
app.poll();
assert!(app.is_folded(1));
assert!(!app.is_folded(0));
}
#[test]
fn only_identified_devices_persist_their_fold() {
let mut app = app_with(ScriptedBackend::boxed(vec![vec![
(Some("a"), 1.0),
(None, 2.0),
]]));
app.poll();
app.selected = 0;
app.apply(Action::Digit(0));
app.selected = 1;
app.apply(Action::Digit(1));
assert!(app.is_folded(0) && app.is_folded(1));
assert_eq!(app.persisted_folds(), vec!["a".to_string()]);
}
#[test]
fn legacy_positional_folds_are_dropped_not_applied() {
let legacy: UiState = serde_json::from_str(
r#"{"folded":[1],"sort_by":"Pid","sort_desc":false,"tick_ms":300}"#,
)
.expect("a pre-identity state file must still parse");
assert!(legacy.folded_devices.is_empty());
assert_eq!(legacy.sort_by, SortBy::Pid);
assert_eq!(legacy.sticky_tick_ms(), Some(300));
let mut app = app_with(ScriptedBackend::boxed(vec![vec![
(Some("a"), 1.0),
(Some("b"), 2.0),
]]));
app.restore_state(&legacy);
app.poll();
assert!(!app.is_folded(0) && !app.is_folded(1));
}
#[test]
fn state_restored_by_id_folds_that_device_wherever_it_sits() {
let state: UiState = serde_json::from_str(
r#"{"folded_devices":["b"],"sort_by":"Pid","sort_desc":false,"tick_ms":0}"#,
)
.unwrap();
let mut app = app_with(ScriptedBackend::boxed(vec![vec![
(Some("c"), 3.0),
(Some("a"), 1.0),
(Some("b"), 2.0),
]]));
app.restore_state(&state);
app.poll();
assert!(app.is_folded(2));
assert!(!app.is_folded(0) && !app.is_folded(1));
}
#[test]
fn unidentifiable_and_duplicate_ids_fall_back_to_position() {
let snap = |id: Option<&str>| GpuSnapshot {
device_id: id.map(str::to_string),
..Default::default()
};
assert_eq!(
device_keys(&[snap(Some("a")), snap(None), snap(Some("a"))]),
vec![
DeviceKey::Id("a".into()),
DeviceKey::Pos(1),
DeviceKey::Pos(2)
]
);
}
#[test]
fn state_for_departed_devices_is_bounded() {
struct ChurnBackend(u64);
impl GpuBackend for ChurnBackend {
fn name(&self) -> &'static str {
"churn"
}
fn poll(&mut self) -> anyhow::Result<Vec<GpuSnapshot>> {
self.0 += 1;
Ok(vec![GpuSnapshot {
device_id: Some(format!("dev-{}", self.0)),
..Default::default()
}])
}
}
let churn = MAX_ABSENT_DEVICES * 3;
let mut app = app_with(Box::new(ChurnBackend(0)));
for _ in 0..churn {
app.poll();
}
assert_eq!(app.history.len(), 1 + MAX_ABSENT_DEVICES);
assert_eq!(app.history.len(), app.session.len());
assert_eq!(app.history.len(), app.last_seen.len());
assert!(app.history.contains_key(app.keys.last().unwrap()));
}
#[test]
fn cgroup_container_detection() {
assert_eq!(
container_of_cgroup(
"0::/system.slice/docker-abcdef123456789000000000000000000000000000000000000000000000dead.scope"
)
.as_deref(),
Some("docker:abcdef123456")
);
assert_eq!(
container_of_cgroup("0::/machine.slice/libpod-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef.scope")
.as_deref(),
Some("podman:0123456789ab")
);
assert_eq!(
container_of_cgroup("0::/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod1234.slice/cri-containerd-fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210.scope")
.as_deref(),
Some("k8s:fedcba987654")
);
assert_eq!(
container_of_cgroup(
"12:pids:/docker/00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff"
)
.as_deref(),
Some("docker:00ff00ff00ff")
);
assert_eq!(
container_of_cgroup("0::/user.slice/user-1000.slice/session-2.scope"),
None
);
assert_eq!(container_of_cgroup("0::/system.slice/sshd.service"), None);
}
#[test]
fn a_saved_state_is_read_back_unchanged_by_the_loader() {
let scratch = Scratch::new("state-roundtrip");
let path = scratch.join("state.json");
let state = UiState {
folded_devices: vec!["GPU-abc".into(), "GPU-def".into()],
sort_by: SortBy::Pid,
sort_desc: false,
tick_ms: 250,
tick_ms_explicit: Some(true),
};
write_state(&path, &state);
let back = read_state(&path).expect("a state that was just saved would not load");
assert_eq!(back.folded_devices, state.folded_devices);
assert_eq!(back.sort_by, state.sort_by);
assert_eq!(back.sort_desc, state.sort_desc);
assert_eq!(back.tick_ms, state.tick_ms);
assert_eq!(back.tick_ms_explicit, state.tick_ms_explicit);
assert!(
!state_temp_path(&path).exists(),
"the temp file outlived a successful save"
);
}
#[test]
fn a_failed_save_leaves_the_previous_state_file_intact() {
let scratch = Scratch::new("state-failed-save");
let path = scratch.join("state.json");
write_state(
&path,
&UiState {
tick_ms: 250,
..Default::default()
},
);
std::fs::create_dir_all(state_temp_path(&path)).unwrap();
write_state(
&path,
&UiState {
tick_ms: 999,
..Default::default()
},
);
let back = read_state(&path).expect("a failed save destroyed the previous state file");
assert_eq!(
back.tick_ms, 250,
"a save that never completed was published anyway"
);
}
}