дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-15 06:05:39 +02:00
Порівняти коміти
1
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
144bea3993 |
No files matched your search
@@ -17,6 +17,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// One row of a process listing.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -104,16 +105,21 @@ pub struct MemoryRoot {
|
||||
}
|
||||
|
||||
/// Memory of one row of the breakdown.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProcessMemory {
|
||||
pub category: MemoryCategory,
|
||||
pub label: String,
|
||||
/// Project, run kind or similar.
|
||||
pub detail: Option<String>,
|
||||
/// The row's top process (the language server, run or terminal shell).
|
||||
pub pid: u32,
|
||||
/// Number of processes counted in this row.
|
||||
pub processes: u32,
|
||||
pub bytes: Option<u64>,
|
||||
/// CPU use since the previous measurement, in percent of one core (Activity
|
||||
/// Monitor's "% CPU"); `None` on the first measurement of every process.
|
||||
pub cpu_percent: Option<f32>,
|
||||
}
|
||||
|
||||
/// Recognises WebView helpers that are our children (WebKitGTK, WebView2).
|
||||
@@ -137,17 +143,21 @@ pub fn attribute(
|
||||
self_pid: u32,
|
||||
roots: &[MemoryRoot],
|
||||
footprint: impl Fn(u32) -> Option<u64>,
|
||||
mut cpu: impl FnMut(u32) -> Option<f32>,
|
||||
) -> Vec<ProcessMemory> {
|
||||
let mut claimed = HashSet::from([self_pid]);
|
||||
let mut out = Vec::new();
|
||||
let mut push = |category, label: String, detail, pids: Vec<u32>| {
|
||||
let sizes: Vec<u64> = pids.iter().filter_map(|&p| footprint(p)).collect();
|
||||
let loads: Vec<f32> = pids.iter().filter_map(|&p| cpu(p)).collect();
|
||||
out.push(ProcessMemory {
|
||||
category,
|
||||
label,
|
||||
detail,
|
||||
pid: pids[0],
|
||||
processes: pids.len() as u32,
|
||||
bytes: (!sizes.is_empty()).then(|| sizes.iter().sum()),
|
||||
cpu_percent: (!loads.is_empty()).then(|| loads.iter().sum()),
|
||||
});
|
||||
};
|
||||
push(MemoryCategory::App, "Backend".into(), None, vec![self_pid]);
|
||||
@@ -180,6 +190,84 @@ pub fn attribute(
|
||||
out
|
||||
}
|
||||
|
||||
/// CPU load per process from the change of its CPU time between measurements.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct CpuSampler {
|
||||
last: HashMap<u32, CpuSample>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct CpuSample {
|
||||
time: Duration,
|
||||
at: Instant,
|
||||
percent: Option<f32>,
|
||||
}
|
||||
|
||||
/// Shorter gaps (several windows polling at once) keep the previous value
|
||||
/// instead of measuring over a few milliseconds.
|
||||
const MIN_CPU_INTERVAL: Duration = Duration::from_millis(500);
|
||||
|
||||
impl CpuSampler {
|
||||
/// Percent of one core used by `pid` since its previous sample.
|
||||
pub fn percent(&mut self, pid: u32) -> Option<f32> {
|
||||
self.record(pid, cpu_time(pid)?, Instant::now())
|
||||
}
|
||||
|
||||
fn record(&mut self, pid: u32, time: Duration, at: Instant) -> Option<f32> {
|
||||
let percent = match self.last.get(&pid) {
|
||||
Some(prev) if at.duration_since(prev.at) < MIN_CPU_INTERVAL => return prev.percent,
|
||||
// A reused pid restarts its CPU time.
|
||||
Some(prev) if time >= prev.time => {
|
||||
Some((time - prev.time).as_secs_f32() / at.duration_since(prev.at).as_secs_f32() * 100.0)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
self.last.insert(pid, CpuSample { time, at, percent });
|
||||
percent
|
||||
}
|
||||
|
||||
/// Forgets processes that are no longer listed.
|
||||
pub fn retain(&mut self, table: &ProcessTable) {
|
||||
if !table.entries.is_empty() {
|
||||
self.last.retain(|pid, _| table.get(*pid).is_some());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// CPU time (user + system) `pid` has used so far.
|
||||
pub fn cpu_time(pid: u32) -> Option<Duration> {
|
||||
imp::cpu_time(pid)
|
||||
}
|
||||
|
||||
/// Ends `pid` and every process it started, children first: a polite request
|
||||
/// (`SIGTERM`), then a forced kill after `grace` for whatever is still running.
|
||||
/// Refuses (returns false) unless `pid` descends from `self_pid`.
|
||||
pub fn end_process_tree(self_pid: u32, pid: u32, grace: Duration) -> bool {
|
||||
let table = ProcessTable::snapshot();
|
||||
if pid == self_pid || !table.tree(self_pid).contains(&pid) {
|
||||
return false;
|
||||
}
|
||||
let pids: Vec<u32> = table.tree(pid).into_iter().rev().collect();
|
||||
for &p in &pids {
|
||||
imp::terminate(p, false);
|
||||
}
|
||||
let deadline = Instant::now() + grace;
|
||||
loop {
|
||||
let now = ProcessTable::snapshot();
|
||||
let left: Vec<u32> = pids.iter().copied().filter(|p| now.get(*p).is_some()).collect();
|
||||
if left.is_empty() {
|
||||
return true;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
for p in left {
|
||||
imp::terminate(p, true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes the OS attributes to `pid` (see the module docs); `None` when the
|
||||
/// process is gone or not readable.
|
||||
pub fn footprint_bytes(pid: u32) -> Option<u64> {
|
||||
@@ -262,6 +350,15 @@ mod imp {
|
||||
fn proc_pidinfo(pid: i32, flavor: i32, arg: u64, buffer: *mut c_void, size: i32) -> i32;
|
||||
fn proc_pid_rusage(pid: i32, flavor: i32, buffer: *mut c_void) -> i32;
|
||||
fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void;
|
||||
fn mach_timebase_info(info: *mut MachTimebaseInfo) -> i32;
|
||||
fn kill(pid: i32, signal: i32) -> i32;
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Default)]
|
||||
struct MachTimebaseInfo {
|
||||
numer: u32,
|
||||
denom: u32,
|
||||
}
|
||||
|
||||
#[link(name = "objc")]
|
||||
@@ -317,6 +414,27 @@ mod imp {
|
||||
(rc == 0).then_some(info[PHYS_FOOTPRINT_WORD])
|
||||
}
|
||||
|
||||
/// `ri_user_time + ri_system_time` (words 2 and 3), in Mach absolute time units.
|
||||
pub fn cpu_time(pid: u32) -> Option<std::time::Duration> {
|
||||
static TIMEBASE: std::sync::OnceLock<(u64, u64)> = std::sync::OnceLock::new();
|
||||
let &(numer, denom) = TIMEBASE.get_or_init(|| {
|
||||
let mut tb = MachTimebaseInfo::default();
|
||||
// SAFETY: `tb` is a writable mach_timebase_info_data_t.
|
||||
let ok = unsafe { mach_timebase_info(&mut tb) } == 0 && tb.denom != 0;
|
||||
if ok { (tb.numer as u64, tb.denom as u64) } else { (1, 1) }
|
||||
});
|
||||
let mut info = [0u64; 64];
|
||||
// SAFETY: `info` (512 bytes) is larger than `rusage_info_v4`.
|
||||
let rc = unsafe { proc_pid_rusage(pid as i32, RUSAGE_INFO_V4, info.as_mut_ptr().cast()) };
|
||||
let ticks = (rc == 0).then(|| info[2].saturating_add(info[3]))?;
|
||||
Some(std::time::Duration::from_nanos((ticks as u128 * numer as u128 / denom as u128) as u64))
|
||||
}
|
||||
|
||||
pub fn terminate(pid: u32, force: bool) {
|
||||
// SAFETY: plain syscall.
|
||||
unsafe { kill(pid as i32, if force { 9 } else { 15 }) };
|
||||
}
|
||||
|
||||
/// `responsibility_get_pid_responsible_for_pid` (libsystem SPI used by
|
||||
/// Activity Monitor), looked up at run time so a missing symbol only
|
||||
/// disables the fallback.
|
||||
@@ -406,6 +524,39 @@ mod imp {
|
||||
None
|
||||
}
|
||||
|
||||
unsafe extern "C" {
|
||||
fn sysconf(name: i32) -> i64;
|
||||
fn kill(pid: i32, signal: i32) -> i32;
|
||||
}
|
||||
const SC_CLK_TCK: i32 = 2;
|
||||
|
||||
/// `utime + stime` of `/proc/<pid>/stat` (fields 14 and 15, in clock ticks).
|
||||
fn stat_ticks(text: &str) -> Option<u64> {
|
||||
let mut rest = text.get(text.rfind(')')? + 1..)?.split_whitespace().skip(11);
|
||||
Some(rest.next()?.parse::<u64>().ok()? + rest.next()?.parse::<u64>().ok()?)
|
||||
}
|
||||
|
||||
pub fn cpu_time(pid: u32) -> Option<std::time::Duration> {
|
||||
static HZ: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
|
||||
// SAFETY: plain libc call.
|
||||
let hz =
|
||||
*HZ.get_or_init(|| u64::try_from(unsafe { sysconf(SC_CLK_TCK) }).ok().filter(|&h| h > 0).unwrap_or(100));
|
||||
let ticks = stat_ticks(&std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?)?;
|
||||
Some(std::time::Duration::from_nanos(ticks * 1_000_000_000 / hz))
|
||||
}
|
||||
|
||||
pub fn terminate(pid: u32, force: bool) {
|
||||
// SAFETY: plain syscall.
|
||||
unsafe { kill(pid as i32, if force { 9 } else { 15 }) };
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn reads_cpu_ticks_from_stat() {
|
||||
let text = "7 (a (b) c) S 3 7 7 0 -1 4194560 100 0 0 0 250 50 0 0 20 0 1 0";
|
||||
assert_eq!(stat_ticks(text), Some(300));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn parses_stat_with_parens_in_name() {
|
||||
@@ -463,8 +614,18 @@ mod imp {
|
||||
fn CloseHandle(handle: *mut c_void) -> i32;
|
||||
fn OpenProcess(access: u32, inherit: i32, pid: u32) -> *mut c_void;
|
||||
fn K32GetProcessMemoryInfo(process: *mut c_void, counters: *mut c_void, cb: u32) -> i32;
|
||||
fn GetProcessTimes(
|
||||
process: *mut c_void,
|
||||
creation: *mut u64,
|
||||
exit: *mut u64,
|
||||
kernel: *mut u64,
|
||||
user: *mut u64,
|
||||
) -> i32;
|
||||
fn TerminateProcess(process: *mut c_void, code: u32) -> i32;
|
||||
}
|
||||
|
||||
const PROCESS_TERMINATE: u32 = 0x1;
|
||||
|
||||
pub fn list_processes() -> Vec<ProcEntry> {
|
||||
// SAFETY: plain Win32 call; the handle is closed below.
|
||||
let snap = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
|
||||
@@ -507,6 +668,34 @@ mod imp {
|
||||
(ok != 0).then_some(c.PrivateUsage as u64)
|
||||
}
|
||||
|
||||
/// Kernel + user time from `GetProcessTimes` (100 ns units).
|
||||
pub fn cpu_time(pid: u32) -> Option<std::time::Duration> {
|
||||
// SAFETY: plain Win32 call; the handle is closed below.
|
||||
let h = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
|
||||
if h.is_null() {
|
||||
return None;
|
||||
}
|
||||
let (mut creation, mut exit, mut kernel, mut user) = (0u64, 0u64, 0u64, 0u64);
|
||||
// SAFETY: `h` is a process handle; the FILETIMEs are written as u64s.
|
||||
let ok = unsafe { GetProcessTimes(h, &mut creation, &mut exit, &mut kernel, &mut user) };
|
||||
// SAFETY: `h` came from OpenProcess.
|
||||
unsafe { CloseHandle(h) };
|
||||
(ok != 0).then(|| std::time::Duration::from_nanos(kernel.saturating_add(user).saturating_mul(100)))
|
||||
}
|
||||
|
||||
/// Windows has no polite request for a console process; both steps terminate it.
|
||||
pub fn terminate(pid: u32, _force: bool) {
|
||||
// SAFETY: plain Win32 call; the handle is closed below.
|
||||
let h = unsafe { OpenProcess(PROCESS_TERMINATE, 0, pid) };
|
||||
if h.is_null() {
|
||||
return;
|
||||
}
|
||||
// SAFETY: `h` is a process handle opened with PROCESS_TERMINATE.
|
||||
unsafe { TerminateProcess(h, 1) };
|
||||
// SAFETY: `h` came from OpenProcess.
|
||||
unsafe { CloseHandle(h) };
|
||||
}
|
||||
|
||||
pub fn responsible_pid(_pid: u32) -> Option<u32> {
|
||||
None
|
||||
}
|
||||
@@ -522,6 +711,10 @@ mod imp {
|
||||
pub fn footprint_bytes(_pid: u32) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
pub fn cpu_time(_pid: u32) -> Option<std::time::Duration> {
|
||||
None
|
||||
}
|
||||
pub fn terminate(_pid: u32, _force: bool) {}
|
||||
pub fn responsible_pid(_pid: u32) -> Option<u32> {
|
||||
None
|
||||
}
|
||||
@@ -589,7 +782,7 @@ mod tests {
|
||||
// Already counted under rust-analyzer: no row.
|
||||
root(301, MemoryCategory::Process, "dup", true),
|
||||
];
|
||||
let rows = attribute(&t, 100, &roots, mb);
|
||||
let rows = attribute(&t, 100, &roots, mb, |pid| (pid == 300 || pid == 301).then_some(25.0));
|
||||
let summary: Vec<(MemoryCategory, &str, u32, Option<u64>)> =
|
||||
rows.iter().map(|r| (r.category, r.label.as_str(), r.processes, r.bytes)).collect();
|
||||
assert_eq!(
|
||||
@@ -606,17 +799,56 @@ mod tests {
|
||||
]
|
||||
);
|
||||
assert_eq!(rows[2].detail.as_deref(), Some("idea"));
|
||||
let pids: Vec<u32> = rows.iter().map(|r| r.pid).collect();
|
||||
assert_eq!(pids, vec![100, 700, 200, 300, 400, 500, 600]);
|
||||
assert_eq!(rows[3].cpu_percent, Some(50.0));
|
||||
assert_eq!(rows[2].cpu_percent, None);
|
||||
assert!(!rows.iter().any(|r| r.label == "Safari"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_rows_have_no_bytes() {
|
||||
let t = table();
|
||||
let rows = attribute(&t, 100, &[root(401, MemoryCategory::Process, "cargo", false)], mb);
|
||||
let rows = attribute(&t, 100, &[root(401, MemoryCategory::Process, "cargo", false)], mb, |_| None);
|
||||
let cargo = rows.iter().find(|r| r.label == "cargo").unwrap();
|
||||
assert_eq!((cargo.processes, cargo.bytes), (1, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_percent_comes_from_the_change_in_cpu_time() {
|
||||
let mut s = CpuSampler::default();
|
||||
let t0 = Instant::now();
|
||||
let ms = Duration::from_millis;
|
||||
assert_eq!(s.record(7, ms(1000), t0), None, "first sample has nothing to compare with");
|
||||
assert_eq!(s.record(7, ms(1500), t0 + ms(1000)), Some(50.0));
|
||||
assert_eq!(s.record(7, ms(1900), t0 + ms(1100)), Some(50.0), "too soon: previous value");
|
||||
assert_eq!(s.record(7, ms(3500), t0 + ms(2000)), Some(200.0), "two busy cores");
|
||||
assert_eq!(s.record(7, ms(10), t0 + ms(3000)), None, "pid reused by a new process");
|
||||
s.retain(&ProcessTable::from_entries([e(8, 1, "other")]));
|
||||
assert_eq!(s.record(7, ms(500), t0 + ms(4000)), None, "forgotten once unlisted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ends_only_descendants() {
|
||||
let me = std::process::id();
|
||||
assert!(!end_process_tree(me, me, Duration::ZERO));
|
||||
assert!(!end_process_tree(me, 1, Duration::ZERO));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ends_a_child_and_its_children() {
|
||||
let mut child = std::process::Command::new("sh").args(["-c", "sleep 30 & wait"]).spawn().unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while ProcessTable::snapshot().children(child.id()).is_empty() && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
let sleeper = ProcessTable::snapshot().children(child.id())[0];
|
||||
assert!(end_process_tree(std::process::id(), child.id(), Duration::from_secs(2)));
|
||||
assert!(child.wait().is_ok());
|
||||
assert!(ProcessTable::snapshot().get(sleeper).is_none(), "grandchild ended too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measures_this_process() {
|
||||
if cfg!(any(target_os = "macos", target_os = "linux", windows)) {
|
||||
@@ -625,6 +857,13 @@ mod tests {
|
||||
assert!(t.get(me).is_some(), "own pid listed");
|
||||
let bytes = footprint_bytes(me).expect("own footprint");
|
||||
assert!(bytes > 1 << 16 && bytes < 1 << 40, "{bytes}");
|
||||
let before = cpu_time(me).expect("own CPU time");
|
||||
let spin = Instant::now();
|
||||
while spin.elapsed() < Duration::from_millis(50) {
|
||||
std::hint::black_box(spin.elapsed());
|
||||
}
|
||||
let used = cpu_time(me).unwrap() - before;
|
||||
assert!(used > Duration::from_millis(10) && used < Duration::from_secs(5), "{used:?}");
|
||||
}
|
||||
if cfg!(target_os = "macos") {
|
||||
assert!(imp::responsible_pid(std::process::id()).is_some(), "responsibility SPI resolved");
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Per-user IDE state
|
||||
/shelf/
|
||||
/changelists.json
|
||||
@@ -637,6 +637,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
plugin::plugin_set_theme_frame,
|
||||
perf::app_metrics,
|
||||
perf::clear_caches,
|
||||
perf::end_process,
|
||||
analysis::inspect_code,
|
||||
analysis::analysis_locate_duplicates,
|
||||
analysis::structural_languages,
|
||||
|
||||
@@ -5,9 +5,10 @@ use crate::state::AppState;
|
||||
use ide_core::IdeResult;
|
||||
use ide_core::event::ServerStatus;
|
||||
use ide_language::SyntaxCacheStats;
|
||||
use ide_workspace::memory::{self, MemoryCategory, MemoryRoot, ProcessMemory, ProcessTable};
|
||||
use ide_workspace::memory::{self, CpuSampler, MemoryCategory, MemoryRoot, ProcessMemory, ProcessTable};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use tauri::{AppHandle, State, Window};
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
|
||||
@@ -55,6 +56,9 @@ fn ms(d: std::time::Duration) -> f64 {
|
||||
d.as_secs_f64() * 1000.0
|
||||
}
|
||||
|
||||
/// CPU time of every measured process at the previous `app_metrics` call, shared by all windows.
|
||||
static CPU: LazyLock<Mutex<CpuSampler>> = LazyLock::new(Default::default);
|
||||
|
||||
/// Snapshot of process memory and subsystem sizes, summed over all projects.
|
||||
/// Async: the process walk runs on the blocking pool, not the main thread.
|
||||
#[tauri::command]
|
||||
@@ -129,7 +133,9 @@ pub async fn app_metrics(app: AppHandle, state: State<'_, AppState>) -> IdeResul
|
||||
.map(|(pid, label)| openidea_root(pid, label.into(), None)),
|
||||
);
|
||||
}
|
||||
Ok(memory::attribute(&table, me, &roots, memory::footprint_bytes))
|
||||
let mut cpu = CPU.lock().unwrap_or_else(|e| e.into_inner());
|
||||
cpu.retain(&table);
|
||||
Ok(memory::attribute(&table, me, &roots, memory::footprint_bytes, |pid| cpu.percent(pid)))
|
||||
})
|
||||
.await?;
|
||||
Ok(m)
|
||||
@@ -219,6 +225,15 @@ pub struct ClearCachesResult {
|
||||
|
||||
/// "Clear caches": drops syntax trees and EditorConfig files of the window's
|
||||
/// project, shrinks the in-memory index and compacts its on-disk cache.
|
||||
/// Ends a process OpenIDEA started (a row of the memory breakdown) and its
|
||||
/// children; its owner (language server, run, terminal) sees it exit. Returns
|
||||
/// false for a process that isn't ours.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn end_process(pid: u32) -> IdeResult<bool> {
|
||||
blocking(move || Ok(memory::end_process_tree(std::process::id(), pid, std::time::Duration::from_secs(3)))).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn clear_caches(window: Window, state: State<'_, AppState>) -> IdeResult<ClearCachesResult> {
|
||||
|
||||
+12
-1
@@ -1545,11 +1545,15 @@ export const commands = {
|
||||
* Async: the process walk runs on the blocking pool, not the main thread.
|
||||
*/
|
||||
appMetrics: () => typedError<AppMetrics, IdeError>(__TAURI_INVOKE("app_metrics")),
|
||||
clearCaches: () => typedError<ClearCachesResult, IdeError>(__TAURI_INVOKE("clear_caches")),
|
||||
/**
|
||||
* "Clear caches": drops syntax trees and EditorConfig files of the window's
|
||||
* project, shrinks the in-memory index and compacts its on-disk cache.
|
||||
* Ends a process OpenIDEA started (a row of the memory breakdown) and its
|
||||
* children; its owner (language server, run, terminal) sees it exit. Returns
|
||||
* false for a process that isn't ours.
|
||||
*/
|
||||
clearCaches: () => typedError<ClearCachesResult, IdeError>(__TAURI_INVOKE("clear_caches")),
|
||||
endProcess: (pid: number) => typedError<boolean, IdeError>(__TAURI_INVOKE("end_process", { pid })),
|
||||
/**
|
||||
* Inspect Code: diagnostics of every file in `scope` that a language server
|
||||
* serves. Files stream through `on_event` as they finish; ends with `Done`.
|
||||
@@ -8534,9 +8538,16 @@ export type ProcessMemory = {
|
||||
label: string,
|
||||
/** Project, run kind or similar. */
|
||||
detail: string | null,
|
||||
/** The row's top process (the language server, run or terminal shell). */
|
||||
pid: number,
|
||||
/** Number of processes counted in this row. */
|
||||
processes: number,
|
||||
bytes: number | null,
|
||||
/**
|
||||
* CPU use since the previous measurement, in percent of one core (Activity
|
||||
* Monitor's "% CPU"); `None` on the first measurement of every process.
|
||||
*/
|
||||
cpuPercent: number | null,
|
||||
};
|
||||
|
||||
export type Profile = {
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import clsx from "clsx";
|
||||
import { BarButton, Button, Popup, T } from "@/ui";
|
||||
import { BarButton, Button, IconButton, Popup, T } from "@/ui";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { isTauri, type AppMetrics } from "@/platform/ipc";
|
||||
import { call, commands, isTauri, type AppMetrics } from "@/platform/ipc";
|
||||
import { formatBytes, formatCount, formatMs, formatSize, formatUptime } from "./format";
|
||||
import { indicatorLabel, indicatorTooltip, memorySections, summarizeMemory } from "./memory";
|
||||
import { indicatorLabel, indicatorTooltip, memorySections, summarizeMemory, type BreakdownRow } from "./memory";
|
||||
import { startPolling } from "./poll";
|
||||
import { METRICS_INTERVAL_MS, refreshMetrics, usePerf } from "./store";
|
||||
import styles from "./perf.module.css";
|
||||
@@ -82,6 +82,40 @@ function Row({ label, detail, value, title, indented }: RowProps) {
|
||||
);
|
||||
}
|
||||
|
||||
async function endProcess(pid: number) {
|
||||
const error = await call(commands.endProcess(pid)).then(
|
||||
() => null,
|
||||
(e: unknown) => String(e),
|
||||
);
|
||||
await refreshMetrics();
|
||||
if (error) usePerf.setState({ error });
|
||||
}
|
||||
|
||||
function ProcessRow({ row }: { row: BreakdownRow }) {
|
||||
const title = row.processes > 1 ? tx`PID ${row.pid} · ${row.processes} processes` : tx`PID ${row.pid}`;
|
||||
return (
|
||||
<div className={clsx(styles.row, styles.indented, styles.processRow)} title={title}>
|
||||
<span className={styles.label}>
|
||||
{row.label}
|
||||
{row.detail && <span className={styles.detail}>{row.detail}</span>}
|
||||
</span>
|
||||
<span className={clsx(styles.value, styles.cpu)}>{row.cpu}</span>
|
||||
<span className={clsx(styles.value, styles.memory)}>{row.value}</span>
|
||||
<span className={styles.end}>
|
||||
{row.endable && (
|
||||
<IconButton
|
||||
icon="close"
|
||||
size="small"
|
||||
iconSize={14}
|
||||
title={tx`End Process ${row.label}`}
|
||||
onClick={() => void endProcess(row.pid)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MemoryDetails({ metrics: m }: { metrics: AppMetrics }) {
|
||||
const error = usePerf((s) => s.error);
|
||||
const s = m.syntax;
|
||||
@@ -89,22 +123,26 @@ export function MemoryDetails({ metrics: m }: { metrics: AppMetrics }) {
|
||||
const sections = memorySections(m.memory ?? []);
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.header}>{tx("Memory & Diagnostics")}</div>
|
||||
<div className={clsx(styles.header, styles.columns)}>
|
||||
<span className={styles.groupTitle}>{tx("Memory & Diagnostics")}</span>
|
||||
<span className={styles.cpu}>
|
||||
<T>CPU</T>
|
||||
</span>
|
||||
<span className={styles.memory}>
|
||||
<T>Memory</T>
|
||||
</span>
|
||||
<span className={styles.end} />
|
||||
</div>
|
||||
{sections.map((sec) => (
|
||||
<div key={sec.title} className={styles.section} aria-label={sec.title} role="group">
|
||||
<div className={styles.groupRow}>
|
||||
<div className={clsx(styles.groupRow, styles.processRow)}>
|
||||
<span className={styles.groupTitle}>{sec.title}</span>
|
||||
<span className={styles.value}>{sec.total}</span>
|
||||
<span className={clsx(styles.value, styles.cpu)}>{sec.cpu}</span>
|
||||
<span className={clsx(styles.value, styles.memory)}>{sec.total}</span>
|
||||
<span className={styles.end} />
|
||||
</div>
|
||||
{sec.rows.map((r) => (
|
||||
<Row
|
||||
key={r.key}
|
||||
label={r.label}
|
||||
detail={r.detail}
|
||||
value={r.value}
|
||||
title={r.processes > 1 ? tx`${r.processes} processes` : undefined}
|
||||
indented
|
||||
/>
|
||||
<ProcessRow key={r.key} row={r} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -141,7 +141,15 @@ describe("polling", () => {
|
||||
});
|
||||
|
||||
const row = (category: ProcessMemory["category"], label: string, mb: number | null, detail: string | null = null) =>
|
||||
({ category, label, detail, processes: 1, bytes: mb == null ? null : mb * MB }) satisfies ProcessMemory;
|
||||
({
|
||||
category,
|
||||
label,
|
||||
detail,
|
||||
pid: 1,
|
||||
processes: 1,
|
||||
bytes: mb == null ? null : mb * MB,
|
||||
cpuPercent: null,
|
||||
}) satisfies ProcessMemory;
|
||||
|
||||
describe("memory breakdown", () => {
|
||||
const rows: ProcessMemory[] = [
|
||||
@@ -150,7 +158,7 @@ describe("memory breakdown", () => {
|
||||
row("app", "GPU", 40),
|
||||
row("app", "Networking", 20),
|
||||
row("languageServer", "jdtls", 900, "idea"),
|
||||
{ ...row("languageServer", "rust-analyzer", 226, "idea"), processes: 2 },
|
||||
{ ...row("languageServer", "rust-analyzer", 226, "idea"), pid: 300, processes: 2, cpuPercent: 12.4 },
|
||||
row("process", "zsh", null, "Terminal"),
|
||||
];
|
||||
|
||||
@@ -179,8 +187,14 @@ describe("memory breakdown", () => {
|
||||
label: "rust-analyzer",
|
||||
detail: "idea",
|
||||
value: "226 MB",
|
||||
cpu: "12%",
|
||||
pid: 300,
|
||||
processes: 2,
|
||||
endable: true,
|
||||
});
|
||||
expect(sections[1]!.cpu).toBe("12%");
|
||||
expect(sections[0]!.cpu).toBe("–");
|
||||
expect(sections[0]!.rows.every((r) => !r.endable)).toBe(true);
|
||||
expect(memorySections([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { clearMocks, mockIPC } from "@tauri-apps/api/mocks";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AppMetrics } from "@/platform/ipc";
|
||||
import { MemoryDetails } from "../MemoryStatus";
|
||||
@@ -22,10 +23,18 @@ const metrics: AppMetrics = {
|
||||
languageServers: 2,
|
||||
syntax: { documents: 4, trees: 2, treeTextBytes: 2 * MB, hits: 10, incrementalParses: 5, fullParses: 2 },
|
||||
memory: [
|
||||
{ category: "app", label: "Backend", detail: null, processes: 1, bytes: 60 * MB },
|
||||
{ category: "app", label: "Web content", detail: "idea", processes: 1, bytes: 300 * MB },
|
||||
{ category: "languageServer", label: "jdtls", detail: "idea", processes: 1, bytes: 900 * MB },
|
||||
{ category: "process", label: "codelldb", detail: null, processes: 1, bytes: 30 * MB },
|
||||
{ category: "app", label: "Backend", detail: null, pid: 10, processes: 1, bytes: 60 * MB, cpuPercent: 4.25 },
|
||||
{ category: "app", label: "Web content", detail: "idea", pid: 11, processes: 1, bytes: 300 * MB, cpuPercent: 1 },
|
||||
{
|
||||
category: "languageServer",
|
||||
label: "jdtls",
|
||||
detail: "idea",
|
||||
pid: 20,
|
||||
processes: 2,
|
||||
bytes: 900 * MB,
|
||||
cpuPercent: 0,
|
||||
},
|
||||
{ category: "process", label: "codelldb", detail: null, pid: 30, processes: 1, bytes: 30 * MB, cpuPercent: null },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -49,6 +58,26 @@ describe("perf views", () => {
|
||||
expect(screen.getByText("Backend malloc in use")).toBeTruthy();
|
||||
expect(screen.getByText("40 ms")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /Clear caches/ })).toBeTruthy();
|
||||
expect(openidea.textContent).toContain("5.3%");
|
||||
expect(servers.textContent).toContain("0%");
|
||||
expect(screen.getByTitle("PID 20 · 2 processes")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("ends language servers and processes, never OpenIDEA's own", async () => {
|
||||
const calls: [string, unknown][] = [];
|
||||
mockIPC((cmd, args) => {
|
||||
calls.push([cmd, args]);
|
||||
if (cmd === "end_process") return true;
|
||||
if (cmd === "app_metrics") return metrics;
|
||||
});
|
||||
render(<MemoryDetails metrics={metrics} />);
|
||||
expect(screen.queryByRole("button", { name: "End Process Backend" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "End Process Web content" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "End Process codelldb" })).toBeTruthy();
|
||||
await act(async () => fireEvent.click(screen.getByRole("button", { name: "End Process jdtls" })));
|
||||
expect(calls[0]).toEqual(["end_process", { pid: 20 }]);
|
||||
expect(calls.some(([cmd]) => cmd === "app_metrics")).toBe(true);
|
||||
clearMocks();
|
||||
});
|
||||
|
||||
it("overlay is off by default and degrades without PerformanceObserver", async () => {
|
||||
|
||||
@@ -29,6 +29,13 @@ export function formatSize(bytes: number | null | undefined): string {
|
||||
return `${trim(bytes / GB)} GB`;
|
||||
}
|
||||
|
||||
/** `0%`, `3.2%`, `45%`, `180%` (percent of one core, so several busy cores exceed 100). */
|
||||
export function formatPercent(p: number | null | undefined): string {
|
||||
if (p == null || !Number.isFinite(p) || p < 0) return "–";
|
||||
if (p < 0.05) return "0%";
|
||||
return p < 10 ? `${trim(p)}%` : `${Math.round(p)}%`;
|
||||
}
|
||||
|
||||
/** `0.4 ms`, `16 ms`, `2.3 s`. */
|
||||
export function formatMs(ms: number | null | undefined): string {
|
||||
if (ms == null || !Number.isFinite(ms)) return "–";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Memory breakdown from `AppMetrics.memory`: totals per group and the popup sections. */
|
||||
import type { AppMetrics, MemoryCategory, ProcessMemory } from "@/platform/ipc";
|
||||
import { formatBytes, formatSize } from "./format";
|
||||
import { formatBytes, formatPercent, formatSize } from "./format";
|
||||
|
||||
export interface MemorySummary {
|
||||
/** Backend + its WebView processes. */
|
||||
@@ -45,15 +45,25 @@ export interface BreakdownRow {
|
||||
/** Project, windows, run kind… */
|
||||
detail: string | null;
|
||||
value: string;
|
||||
cpu: string;
|
||||
pid: number;
|
||||
processes: number;
|
||||
/** A language server, run, terminal or other child the user may end (never OpenIDEA's own processes). */
|
||||
endable: boolean;
|
||||
}
|
||||
|
||||
export interface BreakdownSection {
|
||||
title: string;
|
||||
total: string;
|
||||
cpu: string;
|
||||
rows: BreakdownRow[];
|
||||
}
|
||||
|
||||
function cpuSum(rows: ProcessMemory[]): number | null {
|
||||
const known = rows.filter((r) => r.cpuPercent != null);
|
||||
return known.length ? known.reduce((n, r) => n + r.cpuPercent!, 0) : null;
|
||||
}
|
||||
|
||||
const SECTIONS: Array<{ category: MemoryCategory; title: string }> = [
|
||||
{ category: "app", title: "OpenIDEA" },
|
||||
{ category: "languageServer", title: "Language servers" },
|
||||
@@ -69,12 +79,16 @@ export function memorySections(rows: ProcessMemory[]): BreakdownSection[] {
|
||||
{
|
||||
title,
|
||||
total: formatSize(sum(items)),
|
||||
cpu: formatPercent(cpuSum(items)),
|
||||
rows: items.map((r, i) => ({
|
||||
key: `${category}:${i}:${r.label}`,
|
||||
label: r.label,
|
||||
detail: r.detail,
|
||||
value: formatSize(r.bytes),
|
||||
cpu: formatPercent(r.cpuPercent),
|
||||
pid: r.pid,
|
||||
processes: r.processes,
|
||||
endable: category !== "app",
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
gap: 6px;
|
||||
}
|
||||
.panel {
|
||||
width: 340px;
|
||||
width: 380px;
|
||||
padding: 6px 0;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--fs-ui);
|
||||
@@ -61,6 +61,47 @@
|
||||
color: var(--fg-emphasis);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.columns {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.columns .groupTitle {
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.processRow {
|
||||
padding-right: 4px;
|
||||
}
|
||||
.columns {
|
||||
padding-right: 4px;
|
||||
}
|
||||
.cpu,
|
||||
.memory {
|
||||
flex: none;
|
||||
text-align: right;
|
||||
}
|
||||
.cpu {
|
||||
width: 44px;
|
||||
}
|
||||
.memory {
|
||||
width: 60px;
|
||||
}
|
||||
.processRow .value {
|
||||
margin-left: 0;
|
||||
}
|
||||
.end {
|
||||
flex: none;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
}
|
||||
.end button {
|
||||
opacity: 0;
|
||||
}
|
||||
.processRow:hover .end button,
|
||||
.end button:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
.error {
|
||||
padding: 2px 12px;
|
||||
color: var(--danger);
|
||||
|
||||
@@ -4040,7 +4040,6 @@
|
||||
"Headers ({count})": "Encabezados ({count})",
|
||||
"Headers": "Encabezados",
|
||||
"Delete the macro '{name}'?": "¿Eliminar la macro '{name}'?",
|
||||
"{count} processes": "{count} procesos",
|
||||
"Memory: {usage}": "Memoria: {usage}",
|
||||
" Memory {before} → {after}.": " Memoria {before} → {after}.",
|
||||
"{count} syntax tree dropped, index compacted.{memory}": "{count} árbol sintáctico descartado, índice compactado.{memory}",
|
||||
|
||||
@@ -3904,7 +3904,6 @@
|
||||
"Headers ({count})": "En-têtes ({count})",
|
||||
"Headers": "En-têtes",
|
||||
"Delete the macro '{name}'?": "Supprimer la macro '{name}' ?",
|
||||
"{count} processes": "{count} processus",
|
||||
"Memory: {usage}": "Mémoire : {usage}",
|
||||
" Memory {before} → {after}.": " Mémoire {before} → {after}.",
|
||||
"{count} syntax tree dropped, index compacted.{memory}": "{count} arbre syntaxique supprimé, index compacté.{memory}",
|
||||
|
||||
@@ -4101,7 +4101,6 @@
|
||||
"Headers ({count})": "헤더({count})",
|
||||
"Headers": "헤더",
|
||||
"Delete the macro '{name}'?": "매크로 '{name}'을(를) 삭제하시겠습니까?",
|
||||
"{count} processes": "프로세스 {count}개",
|
||||
"Memory: {usage}": "메모리: {usage}",
|
||||
" Memory {before} → {after}.": " 메모리 {before} → {after}.",
|
||||
"{count} syntax tree dropped, index compacted.{memory}": "구문 트리 {count}개를 삭제하고 색인을 압축했습니다.{memory}",
|
||||
|
||||
@@ -3999,7 +3999,6 @@
|
||||
"Headers ({count})": "Nagłówki ({count})",
|
||||
"Headers": "Nagłówki",
|
||||
"Delete the macro '{name}'?": "Usunąć makro '{name}'?",
|
||||
"{count} processes": "Liczba procesów: {count}",
|
||||
"Memory: {usage}": "Pamięć: {usage}",
|
||||
" Memory {before} → {after}.": " Pamięć {before} → {after}.",
|
||||
"{count} syntax tree dropped, index compacted.{memory}": "Odrzucono drzewo składniowe ({count}), indeks skompresowany.{memory}",
|
||||
|
||||
@@ -4005,7 +4005,6 @@
|
||||
"Headers ({count})": "Cabeçalhos ({count})",
|
||||
"Headers": "Cabeçalhos",
|
||||
"Delete the macro '{name}'?": "Excluir a macro '{name}'?",
|
||||
"{count} processes": "{count} processos",
|
||||
"Memory: {usage}": "Memória: {usage}",
|
||||
" Memory {before} → {after}.": " Memória {before} → {after}.",
|
||||
"{count} syntax tree dropped, index compacted.{memory}": "{count} árvore sintática descartada, índice compactado.{memory}",
|
||||
|
||||
@@ -4718,7 +4718,6 @@
|
||||
"Headers ({count})": "标头 ({count})",
|
||||
"Headers": "标头",
|
||||
"Delete the macro '{name}'?": "是否删除宏 '{name}'?",
|
||||
"{count} processes": "{count} 个进程",
|
||||
"Memory: {usage}": "内存:{usage}",
|
||||
" Memory {before} → {after}.": " 内存 {before} → {after}。",
|
||||
"{count} syntax tree dropped, index compacted.{memory}": "已丢弃 {count} 个语法树,已压缩索引。{memory}",
|
||||
@@ -8161,5 +8160,8 @@
|
||||
"{name} (copy)": "{name}(副本)",
|
||||
"{name} (copy {n})": "{name}(副本 {n})",
|
||||
"New Project Library": "新建项目库",
|
||||
"Copy Library": "复制库"
|
||||
"Copy Library": "复制库",
|
||||
"PID {pid}": "PID {pid}",
|
||||
"PID {pid} · {count} processes": "PID {pid} · {count} 个进程",
|
||||
"End Process {label}": "结束进程 {label}"
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача