Порівняти коміти

...
Автор SHA1 Повідомлення Дата
l0ng-ai 144bea3993 feat(perf): CPU, PID and End Process in the memory popup
The Memory & Diagnostics popup lists every process OpenIDEA runs (backend,
WebView helpers, language servers, runs, terminals) with its memory. It now
also shows each row's CPU use since the previous measurement (percent of one
core, summed over the row's processes) and its PID in the tooltip, and
language server, run and terminal rows get an End Process button.

- ide-workspace: `CpuSampler` turns per-process CPU time (proc_pid_rusage on
  macOS, /proc/<pid>/stat on Linux, GetProcessTimes on Windows) into a
  percentage between measurements; `end_process_tree` ends a process and its
  children (SIGTERM, then SIGKILL after a grace period; TerminateProcess on
  Windows) and refuses any pid that doesn't descend from the backend.
- `ProcessMemory` gains `pid` and `cpuPercent`; new `end_process` command.
  The owner of the process sees it exit as usual (a language server restarts,
  a terminal closes).
2026-11-15 02:54:59 +08:00
17 змінених файлів з 445 додано та 36 видалено

No files matched your search

+242 -3
Переглянути файл
@@ -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");
+3
Переглянути файл
@@ -0,0 +1,3 @@
# Per-user IDE state
/shelf/
/changelists.json
+1
Переглянути файл
@@ -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,
+17 -2
Переглянути файл
@@ -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 = {
+52 -14
Переглянути файл
@@ -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>
))}
+16 -2
Переглянути файл
@@ -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([]);
});
});
+34 -5
Переглянути файл
@@ -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 () => {
+7
Переглянути файл
@@ -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 "–";
+15 -1
Переглянути файл
@@ -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",
})),
},
];
+42 -1
Переглянути файл
@@ -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);
-1
Переглянути файл
@@ -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}",
-1
Переглянути файл
@@ -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}",
-1
Переглянути файл
@@ -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}",
-1
Переглянути файл
@@ -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}",
-1
Переглянути файл
@@ -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}",
+4 -2
Переглянути файл
@@ -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}"
}