mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-10-06 00:02:14 +00:00
feat(panel): CPU% and memory per process, and a total, in Info → Processes (#1064)
* feat(panel): CPU% and memory per process, and a total, in Info → Processes The daemon adds rss, cpu_ns and a start stamp to each ProcEntry (serde default, so an old daemon or GUI skips them). The GUI turns two CPU-time samples into a ps-style % and sums both into a Total line. `tty7 procs` gains an RSS column. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(panel): no CPU% across a poll gap, and test CPU time is in ns A new poll chain starts after the panel was shut or the pane changed; comparing against the sample from before the gap showed a long-run average as the current figure. Reset the tracker so the first round shows a dash. Replace the self-usage test's cpu_ns > 0 (fails on Linux before the first 10 ms tick, passes unconverted mach ticks) with a check that the process's CPU time covers this thread's own burned CPU time. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
l0ng-ai
parent
e1f93704fe
commit
c361bd71e8
@@ -4095,6 +4095,7 @@ mod tests {
|
||||
name: name.into(),
|
||||
depth,
|
||||
foreground,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -289,10 +289,14 @@ pub fn procs_tables(procs: &PaneProcs) -> String {
|
||||
p.pid.to_string(),
|
||||
format!("{}{}", " ".repeat(p.depth as usize), p.name),
|
||||
if p.foreground { "*" } else { "" }.to_string(),
|
||||
p.rss.map_or_else(
|
||||
|| "-".to_string(),
|
||||
tty7_core::daemon::procstat::compact_bytes,
|
||||
),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
let mut out = table(&["PID", "NAME", "FG"], &rows);
|
||||
let mut out = table(&["PID", "NAME", "FG", "RSS"], &rows);
|
||||
if !procs.ports.is_empty() {
|
||||
out.push('\n');
|
||||
let rows: Vec<Vec<String>> = procs
|
||||
@@ -586,12 +590,15 @@ mod tests {
|
||||
name: "pwsh".into(),
|
||||
depth: 0,
|
||||
foreground: false,
|
||||
..Default::default()
|
||||
},
|
||||
ProcEntry {
|
||||
pid: 200,
|
||||
name: "cargo".into(),
|
||||
depth: 1,
|
||||
foreground: true,
|
||||
rss: Some(38 * 1024 * 1024),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
ports: vec![PortEntry {
|
||||
@@ -613,6 +620,11 @@ mod tests {
|
||||
"the foreground process is marked: {rendered}"
|
||||
);
|
||||
assert!(rendered.contains("3000"), "{rendered}");
|
||||
assert!(rendered.contains("RSS"), "{rendered}");
|
||||
assert!(
|
||||
rendered.contains("38 MB"),
|
||||
"rss is appended per row: {rendered}"
|
||||
);
|
||||
assert!(
|
||||
!rendered.contains("note:"),
|
||||
"a probe that worked says nothing: {rendered}"
|
||||
@@ -630,6 +642,7 @@ mod tests {
|
||||
name: "zsh".into(),
|
||||
depth: 0,
|
||||
foreground: true,
|
||||
..Default::default()
|
||||
}],
|
||||
ports: Vec::new(),
|
||||
probe: PortProbe::Unavailable("lsof: program not found".into()),
|
||||
|
||||
@@ -196,6 +196,7 @@ windows-sys = { version = "0.59", features = [
|
||||
"Win32_Security",
|
||||
"Win32_System_Console",
|
||||
"Win32_System_Diagnostics_ToolHelp",
|
||||
"Win32_System_ProcessStatus",
|
||||
"Win32_System_Registry",
|
||||
"Win32_System_Threading",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
|
||||
@@ -13,6 +13,7 @@ pub mod mobile;
|
||||
pub mod pane;
|
||||
pub mod pidfile;
|
||||
pub mod procinfo;
|
||||
pub mod procstat;
|
||||
pub mod protocol;
|
||||
pub(crate) mod remote;
|
||||
pub mod remote_link;
|
||||
|
||||
@@ -31,7 +31,9 @@ pub fn snapshot(shell_pid: u32, fg_pgid: Option<i32>) -> PaneProcs {
|
||||
if let PortProbe::Unavailable(detail) = &probe {
|
||||
note_probe_failure(shell_pid, detail);
|
||||
}
|
||||
finish(procs, ports, probe)
|
||||
let mut out = finish(procs, ports, probe);
|
||||
crate::daemon::procstat::fill(&mut out.procs);
|
||||
out
|
||||
}
|
||||
|
||||
/// How long the same probe failure waits before it is written down again.
|
||||
@@ -209,6 +211,7 @@ fn walk(table: &HashMap<u32, Row>, shell_pid: u32, fg_pgid: Option<i32>) -> Vec<
|
||||
name: row.name.clone(),
|
||||
depth,
|
||||
foreground: fg_pgid.is_some_and(|g| g as u32 == row.pgid),
|
||||
..Default::default()
|
||||
});
|
||||
if depth + 1 > MAX_DEPTH {
|
||||
continue;
|
||||
@@ -1071,18 +1074,21 @@ mod tests {
|
||||
name: "zsh".into(),
|
||||
depth: 0,
|
||||
foreground: false,
|
||||
..Default::default()
|
||||
},
|
||||
ProcEntry {
|
||||
pid: 9000,
|
||||
name: "go".into(),
|
||||
depth: 1,
|
||||
foreground: true,
|
||||
..Default::default()
|
||||
},
|
||||
ProcEntry {
|
||||
pid: 9001,
|
||||
name: "main".into(),
|
||||
depth: 2,
|
||||
foreground: true,
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
let report = "p9001\nf3\nn*:8080\nf5\nn[::]:8080\np100\n";
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
//! Resident memory and CPU time for the Info panel's process rows.
|
||||
//!
|
||||
//! Read per walked pid rather than in `process_table`'s pass over every pid on
|
||||
//! the machine: the panel shows at most `MAX_PROCS` rows, and a failed read on
|
||||
//! another user's process must not drop it from the tree.
|
||||
|
||||
use crate::daemon::protocol::ProcEntry;
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct Usage {
|
||||
pub rss: Option<u64>,
|
||||
pub cpu_ns: Option<u64>,
|
||||
pub started: Option<u64>,
|
||||
}
|
||||
|
||||
pub(crate) fn fill(procs: &mut [ProcEntry]) {
|
||||
for p in procs {
|
||||
let u = read(p.pid);
|
||||
p.rss = u.rss;
|
||||
p.cpu_ns = u.cpu_ns;
|
||||
p.started = u.started;
|
||||
}
|
||||
}
|
||||
|
||||
/// `512 KB`, `38 MB`, `1.2 GB`: binary units under the short names `ps` and
|
||||
/// Activity Monitor print.
|
||||
pub fn compact_bytes(n: u64) -> String {
|
||||
const UNITS: [&str; 4] = ["KB", "MB", "GB", "TB"];
|
||||
if n < 1024 {
|
||||
return format!("{n} B");
|
||||
}
|
||||
let mut v = n as f64 / 1024.0;
|
||||
let mut unit = 0;
|
||||
while v >= 1024.0 && unit < UNITS.len() - 1 {
|
||||
v /= 1024.0;
|
||||
unit += 1;
|
||||
}
|
||||
match v < 10.0 {
|
||||
true => format!("{v:.1} {}", UNITS[unit]),
|
||||
false => format!("{v:.0} {}", UNITS[unit]),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) fn read(pid: u32) -> Usage {
|
||||
let mut info: libc::proc_taskallinfo = unsafe { std::mem::zeroed() };
|
||||
let size = std::mem::size_of::<libc::proc_taskallinfo>() as libc::c_int;
|
||||
// SAFETY: `info` is a correctly sized, writable `proc_taskallinfo`.
|
||||
let ret = unsafe {
|
||||
libc::proc_pidinfo(
|
||||
pid as libc::c_int,
|
||||
libc::PROC_PIDTASKALLINFO,
|
||||
0,
|
||||
&mut info as *mut _ as *mut libc::c_void,
|
||||
size,
|
||||
)
|
||||
};
|
||||
if ret != size {
|
||||
return Usage::default();
|
||||
}
|
||||
let t = &info.ptinfo;
|
||||
let b = &info.pbsd;
|
||||
Usage {
|
||||
rss: Some(t.pti_resident_size),
|
||||
// Mach ticks, not nanoseconds: 1:1 on Intel, 125:3 on Apple silicon.
|
||||
cpu_ns: Some(mach_to_ns(t.pti_total_user + t.pti_total_system)),
|
||||
started: Some(b.pbi_start_tvsec * 1_000_000 + b.pbi_start_tvusec),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
// libc deprecates these in favour of `mach2`; not worth a dependency.
|
||||
#[allow(deprecated)]
|
||||
fn mach_to_ns(ticks: u64) -> u64 {
|
||||
static TIMEBASE: std::sync::OnceLock<(u64, u64)> = std::sync::OnceLock::new();
|
||||
let (numer, denom) = *TIMEBASE.get_or_init(|| {
|
||||
let mut tb = libc::mach_timebase_info { numer: 0, denom: 0 };
|
||||
// SAFETY: writes the two fields of `tb` and nothing else.
|
||||
let ok = unsafe { libc::mach_timebase_info(&mut tb) } == 0 && tb.denom != 0;
|
||||
match ok {
|
||||
true => (tb.numer as u64, tb.denom as u64),
|
||||
false => (1, 1),
|
||||
}
|
||||
});
|
||||
(ticks as u128 * numer as u128 / denom as u128) as u64
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn read(pid: u32) -> Usage {
|
||||
match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
|
||||
Ok(stat) => parse_stat(
|
||||
&stat,
|
||||
sysconf(libc::_SC_CLK_TCK),
|
||||
sysconf(libc::_SC_PAGESIZE),
|
||||
),
|
||||
Err(_) => Usage::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn sysconf(name: libc::c_int) -> u64 {
|
||||
// SAFETY: `sysconf` only reads a configuration value.
|
||||
let v = unsafe { libc::sysconf(name) };
|
||||
if v > 0 { v as u64 } else { 0 }
|
||||
}
|
||||
|
||||
/// utime, stime, starttime and rss out of a `/proc/<pid>/stat` line — fields
|
||||
/// 14, 15, 22 and 24, counted from the `)` that closes the command name, which
|
||||
/// may itself hold spaces.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
fn parse_stat(stat: &str, ticks_per_sec: u64, page: u64) -> Usage {
|
||||
let Some(close) = stat.rfind(')') else {
|
||||
return Usage::default();
|
||||
};
|
||||
let f: Vec<u64> = stat[close + 1..]
|
||||
.split_whitespace()
|
||||
.map(|s| s.parse().unwrap_or(0))
|
||||
.collect();
|
||||
if f.len() < 22 || ticks_per_sec == 0 {
|
||||
return Usage::default();
|
||||
}
|
||||
Usage {
|
||||
rss: (page > 0).then(|| f[21] * page),
|
||||
cpu_ns: Some((f[11] + f[12]) * (1_000_000_000 / ticks_per_sec)),
|
||||
started: Some(f[19]),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn read(pid: u32) -> Usage {
|
||||
use windows_sys::Win32::Foundation::{CloseHandle, FILETIME};
|
||||
use windows_sys::Win32::System::ProcessStatus::{
|
||||
K32GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
|
||||
};
|
||||
let ft = |t: FILETIME| (t.dwHighDateTime as u64) << 32 | t.dwLowDateTime as u64;
|
||||
let mut u = Usage::default();
|
||||
// SAFETY: every out-pointer is a zeroed local of the type the call expects,
|
||||
// and the handle is closed on the only path that opened it.
|
||||
unsafe {
|
||||
let h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
|
||||
if h.is_null() {
|
||||
return u;
|
||||
}
|
||||
let (mut created, mut exited, mut kernel, mut user): (
|
||||
FILETIME,
|
||||
FILETIME,
|
||||
FILETIME,
|
||||
FILETIME,
|
||||
) = std::mem::zeroed();
|
||||
if GetProcessTimes(h, &mut created, &mut exited, &mut kernel, &mut user) != 0 {
|
||||
// FILETIME is in 100 ns units.
|
||||
u.cpu_ns = Some((ft(kernel) + ft(user)) * 100);
|
||||
u.started = Some(ft(created));
|
||||
}
|
||||
let mut mem: PROCESS_MEMORY_COUNTERS = std::mem::zeroed();
|
||||
let cb = std::mem::size_of::<PROCESS_MEMORY_COUNTERS>() as u32;
|
||||
if K32GetProcessMemoryInfo(h, &mut mem, cb) != 0 {
|
||||
u.rss = Some(mem.WorkingSetSize as u64);
|
||||
}
|
||||
CloseHandle(h);
|
||||
}
|
||||
u
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
|
||||
pub(crate) fn read(_pid: u32) -> Usage {
|
||||
Usage::default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bytes_read_compactly() {
|
||||
assert_eq!(compact_bytes(512), "512 B");
|
||||
assert_eq!(compact_bytes(512 * 1024), "512 KB");
|
||||
assert_eq!(compact_bytes(38 * 1024 * 1024), "38 MB");
|
||||
assert_eq!(compact_bytes(1_288_490_189), "1.2 GB");
|
||||
assert_eq!(compact_bytes(5 * 1024 * 1024 + 300 * 1024), "5.3 MB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_old_daemons_entry_has_no_usage() {
|
||||
let old = r#"{"pid":1,"name":"zsh","depth":0,"foreground":true}"#;
|
||||
let p: ProcEntry = serde_json::from_str(old).unwrap();
|
||||
assert_eq!((p.rss, p.cpu_ns, p.started), (None, None, None));
|
||||
assert_eq!(
|
||||
serde_json::to_string(&p).unwrap(),
|
||||
old,
|
||||
"no nulls sent back"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stat_line_with_spaces_in_the_name_parses() {
|
||||
let stat = "42 (tmux: server) S 1 42 42 0 -1 4194560 100 0 0 0 250 50 0 0 20 0 1 0 \
|
||||
9000 12345678 2048 18446744073709551615";
|
||||
let u = parse_stat(stat, 100, 4096);
|
||||
assert_eq!(u.rss, Some(2048 * 4096));
|
||||
assert_eq!(u.cpu_ns, Some(3_000_000_000));
|
||||
assert_eq!(u.started, Some(9000));
|
||||
assert_eq!(parse_stat("garbage", 100, 4096), Usage::default());
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
#[test]
|
||||
fn this_process_reports_plausible_usage() {
|
||||
let u = read(std::process::id());
|
||||
let rss = u.rss.expect("own rss");
|
||||
assert!((1 << 20..1 << 40).contains(&rss), "rss {rss}");
|
||||
assert!(u.cpu_ns.is_some(), "{u:?}");
|
||||
assert!(u.started.is_some_and(|s| s > 0), "{u:?}");
|
||||
}
|
||||
|
||||
/// The process's CPU time is at least this thread's, in the same unit.
|
||||
/// Unconverted mach ticks read ~42x low on Apple silicon; a bare `> 0`
|
||||
/// would pass them, and would fail on Linux before the first 10 ms tick.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
#[test]
|
||||
fn cpu_time_is_in_nanoseconds() {
|
||||
fn thread_ns() -> u64 {
|
||||
let mut ts = libc::timespec {
|
||||
tv_sec: 0,
|
||||
tv_nsec: 0,
|
||||
};
|
||||
// SAFETY: writes `ts` and nothing else.
|
||||
unsafe { libc::clock_gettime(libc::CLOCK_THREAD_CPUTIME_ID, &mut ts) };
|
||||
ts.tv_sec as u64 * 1_000_000_000 + ts.tv_nsec as u64
|
||||
}
|
||||
let before = read(std::process::id()).cpu_ns.expect("cpu time");
|
||||
let t0 = thread_ns();
|
||||
let mut x = 0u64;
|
||||
while thread_ns() - t0 < 200_000_000 {
|
||||
x = std::hint::black_box(x.wrapping_mul(31).wrapping_add(1));
|
||||
}
|
||||
let burned = thread_ns() - t0;
|
||||
let after = read(std::process::id()).cpu_ns.expect("cpu time");
|
||||
// Slack for Linux's 10 ms clock tick on each end.
|
||||
assert!(
|
||||
after - before + 30_000_000 >= burned,
|
||||
"process cpu went up {} ns while this thread alone burned {burned}",
|
||||
after - before
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -464,13 +464,23 @@ pub struct ManagedForward {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ProcEntry {
|
||||
pub pid: u32,
|
||||
pub name: String,
|
||||
pub depth: u8,
|
||||
#[serde(default)]
|
||||
pub foreground: bool,
|
||||
/// Resident memory in bytes, total CPU time (user + system) in ns, and an
|
||||
/// opaque start stamp that tells a reused pid from the process it replaced.
|
||||
/// `None` from a daemon that predates them or could not read them; not a
|
||||
/// `PROTOCOL_VERSION` bump, since either side skips what it does not know.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub rss: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cpu_ns: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub started: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
||||
@@ -1831,6 +1831,7 @@ mod aggregate_tests {
|
||||
name: "node".into(),
|
||||
depth: 0,
|
||||
foreground: true,
|
||||
..Default::default()
|
||||
}],
|
||||
ports: vec![crate::daemon::protocol::PortEntry {
|
||||
port: 3000,
|
||||
|
||||
@@ -1379,6 +1379,7 @@ pub fn translate_en(key: L10nKey) -> &'static str {
|
||||
L10nKey::PanelNoChangesHint => "The working tree is clean.",
|
||||
L10nKey::PanelSessionSubtitle => "Session",
|
||||
L10nKey::PanelProcessesSubtitle => "Processes",
|
||||
L10nKey::PanelProcessesTotal => "Total",
|
||||
L10nKey::PanelPortsSubtitle => "Ports",
|
||||
L10nKey::PanelPortsUnsupported => "That machine's tty7-server is too old to list ports.",
|
||||
L10nKey::PanelPortsProbeFailed => "Couldn't check what this pane is listening on.",
|
||||
|
||||
@@ -1441,6 +1441,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::PanelNoChangesHint => "ワーキングツリーはクリーンです",
|
||||
L10nKey::PanelSessionSubtitle => "セッション",
|
||||
L10nKey::PanelProcessesSubtitle => "プロセス",
|
||||
L10nKey::PanelProcessesTotal => "合計",
|
||||
L10nKey::PanelPortsSubtitle => "ポート",
|
||||
L10nKey::PanelLatency => "遅延",
|
||||
L10nKey::PanelPortsUnsupported => "リモートの tty7-server が古く、ポートを列挙できません。",
|
||||
|
||||
@@ -1076,6 +1076,7 @@ l10n_keys! {
|
||||
PanelMoreChangedFiles,
|
||||
PanelSessionSubtitle,
|
||||
PanelProcessesSubtitle,
|
||||
PanelProcessesTotal,
|
||||
PanelPortsSubtitle,
|
||||
PanelPortsUnsupported,
|
||||
PanelPortsProbeFailed,
|
||||
|
||||
@@ -1287,6 +1287,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::PanelNoChangesHint => "worktree 是干净的。",
|
||||
L10nKey::PanelSessionSubtitle => "会话",
|
||||
L10nKey::PanelProcessesSubtitle => "进程",
|
||||
L10nKey::PanelProcessesTotal => "合计",
|
||||
L10nKey::PanelPortsSubtitle => "端口",
|
||||
L10nKey::PanelLatency => "延迟",
|
||||
L10nKey::PanelPortsUnsupported => "对端的 tty7-server 太旧,列不出端口。",
|
||||
|
||||
@@ -35,6 +35,7 @@ pub mod pending_pane;
|
||||
pub mod perf;
|
||||
pub mod prefill;
|
||||
pub mod presets;
|
||||
pub mod proc_usage;
|
||||
pub mod prompt;
|
||||
pub mod remote_connect;
|
||||
pub mod remote_workspace;
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
//! CPU% and memory for Info → Processes.
|
||||
//!
|
||||
//! The daemon sends raw CPU time; the percentage needs two samples of the same
|
||||
//! process, so it is worked out here, where the panel's poll keeps state
|
||||
//! between rounds and the protocol stays stateless.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Instant;
|
||||
|
||||
use gpui::{App, IntoElement, ParentElement as _, SharedString, Styled as _, div, px, rems};
|
||||
use gpui_component::{ActiveTheme as _, h_flex};
|
||||
|
||||
use crate::daemon::procstat::compact_bytes;
|
||||
use crate::daemon::protocol::ProcEntry;
|
||||
use crate::ui::i18n::{L10nKey, t};
|
||||
use crate::ui::right_panel::{META, META_MONO};
|
||||
|
||||
const CPU_W: f32 = 40.;
|
||||
const MEM_W: f32 = 52.;
|
||||
const PID_W: f32 = 44.;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct Sample {
|
||||
started: Option<u64>,
|
||||
cpu_ns: u64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct CpuTracker {
|
||||
prev: HashMap<u32, Sample>,
|
||||
at: Option<Instant>,
|
||||
pct: HashMap<u32, f64>,
|
||||
}
|
||||
|
||||
impl CpuTracker {
|
||||
pub(crate) fn sample(&mut self, procs: &[ProcEntry], now: Instant) {
|
||||
let elapsed_ns = self
|
||||
.at
|
||||
.map_or(0, |at| now.saturating_duration_since(at).as_nanos() as u64);
|
||||
let mut next = HashMap::new();
|
||||
self.pct.clear();
|
||||
for p in procs {
|
||||
let Some(cpu_ns) = p.cpu_ns else { continue };
|
||||
let cur = Sample {
|
||||
started: p.started,
|
||||
cpu_ns,
|
||||
};
|
||||
if let Some(pct) = cpu_percent(self.prev.get(&p.pid).copied(), cur, elapsed_ns) {
|
||||
self.pct.insert(p.pid, pct);
|
||||
}
|
||||
next.insert(p.pid, cur);
|
||||
}
|
||||
self.prev = next;
|
||||
self.at = Some(now);
|
||||
}
|
||||
|
||||
pub(crate) fn percent(&self, pid: u32) -> Option<f64> {
|
||||
self.pct.get(&pid).copied()
|
||||
}
|
||||
}
|
||||
|
||||
/// `ps`'s %CPU over the interval: 100 is one core busy the whole time. `None`
|
||||
/// for a first sample, a pid now naming a different process, a clock that did
|
||||
/// not move, or a CPU time that went backwards.
|
||||
fn cpu_percent(prev: Option<Sample>, cur: Sample, elapsed_ns: u64) -> Option<f64> {
|
||||
let prev = prev?;
|
||||
if elapsed_ns == 0 || prev.started != cur.started || cur.cpu_ns < prev.cpu_ns {
|
||||
return None;
|
||||
}
|
||||
Some((cur.cpu_ns - prev.cpu_ns) as f64 * 100.0 / elapsed_ns as f64)
|
||||
}
|
||||
|
||||
/// Summed CPU% and RSS over the rows that report them; `None` when none do.
|
||||
pub(crate) fn totals(procs: &[ProcEntry], cpu: &CpuTracker) -> (Option<f64>, Option<u64>) {
|
||||
let pcts: Vec<f64> = procs.iter().filter_map(|p| cpu.percent(p.pid)).collect();
|
||||
let rss: Vec<u64> = procs.iter().filter_map(|p| p.rss).collect();
|
||||
(
|
||||
(!pcts.is_empty()).then(|| pcts.iter().sum()),
|
||||
(!rss.is_empty()).then(|| rss.iter().sum()),
|
||||
)
|
||||
}
|
||||
|
||||
fn format_cpu(pct: f64) -> String {
|
||||
match pct < 10.0 {
|
||||
true => format!("{pct:.1}%"),
|
||||
false => format!("{pct:.0}%"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The CPU cell: blank for a daemon that sends no CPU time, a dash for a
|
||||
/// process seen once so far.
|
||||
fn cpu_text(reported: bool, pct: Option<f64>) -> String {
|
||||
match (reported, pct) {
|
||||
(_, Some(pct)) => format_cpu(pct),
|
||||
(true, None) => "–".into(),
|
||||
(false, None) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn cell(text: String, w: f32, mono: SharedString, cx: &App) -> impl IntoElement {
|
||||
div()
|
||||
.flex_none()
|
||||
.w(px(w))
|
||||
.flex()
|
||||
.justify_end()
|
||||
.text_size(rems(META_MONO))
|
||||
.font_family(mono)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(text)
|
||||
}
|
||||
|
||||
/// CPU%, memory and pid, right-aligned in fixed columns so rows line up.
|
||||
pub(crate) fn row_cells(
|
||||
p: &ProcEntry,
|
||||
cpu: &CpuTracker,
|
||||
mono: SharedString,
|
||||
cx: &App,
|
||||
) -> impl IntoElement {
|
||||
h_flex()
|
||||
.flex_none()
|
||||
.gap(px(6.))
|
||||
.child(cell(
|
||||
cpu_text(p.cpu_ns.is_some(), cpu.percent(p.pid)),
|
||||
CPU_W,
|
||||
mono.clone(),
|
||||
cx,
|
||||
))
|
||||
.child(cell(
|
||||
p.rss.map(compact_bytes).unwrap_or_default(),
|
||||
MEM_W,
|
||||
mono.clone(),
|
||||
cx,
|
||||
))
|
||||
.child(cell(p.pid.to_string(), PID_W, mono, cx))
|
||||
}
|
||||
|
||||
/// The Total line under the tree, or nothing from a daemon that reports no
|
||||
/// usage at all.
|
||||
pub(crate) fn total_row(
|
||||
procs: &[ProcEntry],
|
||||
cpu: &CpuTracker,
|
||||
mono: SharedString,
|
||||
cx: &App,
|
||||
) -> Option<gpui::Div> {
|
||||
let reported = procs.iter().any(|p| p.cpu_ns.is_some());
|
||||
let (pct, rss) = totals(procs, cpu);
|
||||
if !reported && rss.is_none() {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
h_flex()
|
||||
.h(px(super::right_panel::PROC_ROW_H))
|
||||
.px(px(super::right_panel::ROW_INSET))
|
||||
.items_center()
|
||||
.gap(px(6.))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.text_size(rems(META))
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(t(L10nKey::PanelProcessesTotal).to_string()),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_none()
|
||||
.gap(px(6.))
|
||||
.child(cell(cpu_text(reported, pct), CPU_W, mono.clone(), cx))
|
||||
.child(cell(
|
||||
rss.map(compact_bytes).unwrap_or_default(),
|
||||
MEM_W,
|
||||
mono.clone(),
|
||||
cx,
|
||||
))
|
||||
.child(cell(String::new(), PID_W, mono, cx)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
fn s(started: u64, cpu_ns: u64) -> Sample {
|
||||
Sample {
|
||||
started: Some(started),
|
||||
cpu_ns,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_percent_is_ps_style() {
|
||||
assert_eq!(
|
||||
cpu_percent(Some(s(1, 0)), s(1, 500_000_000), 1_000_000_000),
|
||||
Some(50.0)
|
||||
);
|
||||
assert_eq!(
|
||||
cpu_percent(Some(s(1, 0)), s(1, 2_000_000_000), 1_000_000_000),
|
||||
Some(200.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_percent_without_a_fair_comparison() {
|
||||
assert_eq!(cpu_percent(None, s(1, 10), 1_000), None, "first sample");
|
||||
assert_eq!(
|
||||
cpu_percent(Some(s(1, 0)), s(2, 10), 1_000),
|
||||
None,
|
||||
"pid reused"
|
||||
);
|
||||
assert_eq!(
|
||||
cpu_percent(Some(s(1, 0)), s(1, 10), 0),
|
||||
None,
|
||||
"zero interval"
|
||||
);
|
||||
assert_eq!(
|
||||
cpu_percent(Some(s(1, 50)), s(1, 10), 1_000),
|
||||
None,
|
||||
"went backwards"
|
||||
);
|
||||
}
|
||||
|
||||
fn proc(pid: u32, rss: Option<u64>, cpu_ns: Option<u64>) -> ProcEntry {
|
||||
ProcEntry {
|
||||
pid,
|
||||
rss,
|
||||
cpu_ns,
|
||||
started: Some(7),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn totals_sum_what_was_reported() {
|
||||
let t0 = Instant::now();
|
||||
let mut cpu = CpuTracker::default();
|
||||
cpu.sample(&[proc(1, None, Some(0)), proc(2, None, Some(0))], t0);
|
||||
assert_eq!(cpu.percent(1), None, "first sample shows a dash");
|
||||
let procs = [
|
||||
proc(1, Some(1024), Some(250_000_000)),
|
||||
proc(2, Some(2048), Some(500_000_000)),
|
||||
proc(3, None, None),
|
||||
];
|
||||
cpu.sample(&procs, t0 + Duration::from_secs(1));
|
||||
assert_eq!(totals(&procs, &cpu), (Some(75.0), Some(3072)));
|
||||
assert_eq!(totals(&[proc(3, None, None)], &cpu), (None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_cells_read_blank_dash_or_percent() {
|
||||
assert_eq!(cpu_text(false, None), "");
|
||||
assert_eq!(cpu_text(true, None), "–");
|
||||
assert_eq!(cpu_text(true, Some(12.4)), "12%");
|
||||
assert_eq!(cpu_text(true, Some(0.34)), "0.3%");
|
||||
}
|
||||
}
|
||||
+17
-9
@@ -102,7 +102,7 @@ const INFO_ROW_H: f32 = 28.;
|
||||
|
||||
/// How tall a process row is — a notch tighter than the Session rows, since
|
||||
/// the list is read as one block rather than line by line.
|
||||
const PROC_ROW_H: f32 = 26.;
|
||||
pub(crate) const PROC_ROW_H: f32 = 26.;
|
||||
|
||||
/// How far a child process sits in from its parent, the width of the `└`
|
||||
/// elbow that marks it.
|
||||
@@ -252,6 +252,7 @@ const SEARCH_GLYPH_GAP: f32 = 3.;
|
||||
pub(crate) struct RightPanelState {
|
||||
pub(crate) procs_pane: Option<u64>,
|
||||
pub(crate) procs: Option<PaneProcs>,
|
||||
pub(crate) cpu: crate::ui::proc_usage::CpuTracker,
|
||||
pub(crate) procs_loading: bool,
|
||||
pub(crate) procs_gen: u64,
|
||||
pub(crate) procs_forwards: Option<crate::ui::app::ForwardRoute>,
|
||||
@@ -1352,16 +1353,16 @@ impl Tty7App {
|
||||
.when(p.foreground, |d| d.font_weight(gpui::FontWeight::MEDIUM))
|
||||
.child(p.name.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.text_size(rems(META_MONO))
|
||||
.font_family(mono.clone())
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(p.pid.to_string()),
|
||||
),
|
||||
.child(crate::ui::proc_usage::row_cells(
|
||||
p,
|
||||
&self.right_panel.cpu,
|
||||
mono.clone(),
|
||||
cx,
|
||||
)),
|
||||
);
|
||||
}
|
||||
let total = crate::ui::proc_usage::total_row(procs, &self.right_panel.cpu, mono, cx);
|
||||
let list = list.children(total);
|
||||
Some(
|
||||
v_flex()
|
||||
.child(self.panel_subtitle(t(L10nKey::PanelProcessesSubtitle), None, cx))
|
||||
@@ -1872,6 +1873,10 @@ impl Tty7App {
|
||||
// empties.
|
||||
if !self.right_panel.procs_loading && self.procs_wanted() {
|
||||
self.right_panel.procs_loading = true;
|
||||
// A new chain follows a gap — the panel was shut, or the pane
|
||||
// changed — and a CPU% across the gap would be its long average
|
||||
// shown as if it were now. A dash for one round is honest.
|
||||
self.right_panel.cpu = Default::default();
|
||||
let generation = self.right_panel.procs_gen;
|
||||
self.spawn_procs_query(pane_id, generation, forwards, host, cx);
|
||||
}
|
||||
@@ -1929,6 +1934,9 @@ impl Tty7App {
|
||||
// answer with in place: blanking it on one failed poll
|
||||
// would make the panel flicker on a link that hiccups.
|
||||
if let Some(procs) = procs {
|
||||
app.right_panel
|
||||
.cpu
|
||||
.sample(&procs.procs, std::time::Instant::now());
|
||||
app.right_panel.procs = Some(procs);
|
||||
}
|
||||
if forwards.is_some() {
|
||||
|
||||
Reference in New Issue
Block a user