diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e7568e1..44de37c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -151,6 +151,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Performance Probe — monitor UI, overlay pins, and live metrics + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#890](https://github.com/Psychotoxical/psysonic/pull/890)** + +* **Ctrl+Shift+D** modal: **Monitor** tab (metric cards with pin-to-overlay) and **Toggles** tab (tree of probe flags/phases). +* Live CPU/memory polling: process CPU, RSS by group, thread CPU groups (Linux `/proc`); **macOS** process CPU + RSS via `sysinfo`. +* HUD overlay: FPS always on top; pinned live metrics with **1-minute sparklines**; Analysis/Cover pipeline blocks; corner + opacity controls. +* Cover pipeline stats in the probe (per-server cache, ensure/peek queues). + + + ## Changed ### Linux — session GDK, WebKitGTK mitigations, and Wayland text @@ -276,6 +287,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Library browse — chunked local catalogs and unified in-page scroll + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#890](https://github.com/Psychotoxical/psysonic/pull/890)** + +* **Albums / Artists**: local index loaded in **200-row SQL chunks** instead of a single ~50k-row fetch; filters preserved. +* **All Albums**: client-slice infinite scroll on the local index (Artists-style). +* Shared in-page scroll hooks and sentinel UI across browse routes; album SQL pagination prioritized over cover ensures. + + + ## Fixed ### Analytics — Opus waveform and loudness analysis @@ -494,6 +515,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Library browse & covers — scroll stability and cover loading + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#890](https://github.com/Psychotoxical/psysonic/pull/890)** + +* In-page infinite scroll stabilized; cover memory caches capped; covers keep loading during active grid scroll. +* **New Releases** and **Lossless** grids use the correct in-page scroll root; cover ensure invoke pump no longer sticks; viewport priority tiers for ensure/peek. +* Performance Probe overlay: fixed blank page from unstable sparkline history; synchronized poll clock and bar/sparkline tick jitter. + + + ## [1.46.0] - 2026-05-18 > **🙏 Special thanks to [@zz5zz](https://github.com/zz5zz)** for his tireless quirk-spotting and bug reports on the [Psysonic Discord](https://discord.gg/AMnDRErm4u) — several of the polish fixes in this release landed directly off the back of his messages. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a0e9fe26..ec52e915 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3103,6 +3103,15 @@ dependencies = [ "memoffset 0.7.1", ] +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -5607,6 +5616,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" dependencies = [ "libc", + "memchr", + "ntapi", "objc2-core-foundation", "objc2-io-kit", "windows 0.62.2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 02b82802..ee61b30c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -67,7 +67,7 @@ discord-rich-presence = "1.1" url = "2" thread-priority = "3" lofty = "0.24" -sysinfo = { version = "0.38", default-features = false, features = ["disk"] } +sysinfo = { version = "0.38", default-features = false, features = ["disk", "system"] } id3 = "1.16.4" symphonia-adapter-libopus = "0.2.9" rusqlite = { version = "0.39", features = ["bundled"] } diff --git a/src-tauri/crates/psysonic-library/src/commands.rs b/src-tauri/crates/psysonic-library/src/commands.rs index 7b3ceaef..f52dfa3a 100644 --- a/src-tauri/crates/psysonic-library/src/commands.rs +++ b/src-tauri/crates/psysonic-library/src/commands.rs @@ -41,6 +41,17 @@ use crate::sync::initial::InitialSyncRunner; use crate::sync::progress::{ChannelProgress, Progress, ProgressEvent}; use crate::sync::tombstone::should_auto_reconcile; +/// Run synchronous SQLite / library read work off the async runtime worker. +async fn library_spawn_blocking(f: F) -> Result +where + F: FnOnce() -> Result + Send + 'static, + R: Send + 'static, +{ + tauri::async_runtime::spawn_blocking(f) + .await + .map_err(|e| format!("library blocking worker failed: {e}"))? +} + /// Cap for `library_get_tracks_batch` per spec §7.1 ("max 100 refs/call"). const TRACKS_BATCH_LIMIT: usize = 100; const ANALYSIS_PROGRESS_CACHE_TTL: Duration = Duration::from_secs(30); @@ -458,7 +469,8 @@ pub async fn library_advanced_search( runtime: State<'_, LibraryRuntime>, request: LibraryAdvancedSearchRequest, ) -> Result { - advanced_search::run_advanced_search(&runtime.store, &request) + let store = Arc::clone(&runtime.store); + library_spawn_blocking(move || advanced_search::run_advanced_search(&store, &request)).await } #[tauri::command] @@ -466,7 +478,8 @@ pub async fn library_list_lossless_albums( runtime: State<'_, LibraryRuntime>, request: crate::dto::LibraryLosslessAlbumsRequest, ) -> Result { - crate::lossless_albums::list_lossless_albums(&runtime.store, &request) + let store = Arc::clone(&runtime.store); + library_spawn_blocking(move || crate::lossless_albums::list_lossless_albums(&store, &request)).await } #[tauri::command] diff --git a/src-tauri/src/cover_cache/backfill_worker.rs b/src-tauri/src/cover_cache/backfill_worker.rs index 218642fc..ff91af10 100644 --- a/src-tauri/src/cover_cache/backfill_worker.rs +++ b/src-tauri/src/cover_cache/backfill_worker.rs @@ -94,6 +94,14 @@ impl CoverBackfillWorker { pub async fn reset_cursor(&self) { *self.cursor.lock().await = String::new(); } + + /// Semaphore-backed library backfill HTTP slots (perf probe). + pub fn pipeline_http_stats(&self) -> (u32, u32, bool) { + let max = LIBRARY_BACKFILL_PARALLEL as u32; + let active = max.saturating_sub(self.backfill_http.available_permits() as u32); + let pass_running = self.pass_running.load(Ordering::Relaxed); + (max, active, pass_running) + } } fn sync_allows_cover_backfill(store: &psysonic_library::store::LibraryStore, server_id: &str) -> bool { diff --git a/src-tauri/src/cover_cache/mod.rs b/src-tauri/src/cover_cache/mod.rs index b30bda86..57ead09f 100644 --- a/src-tauri/src/cover_cache/mod.rs +++ b/src-tauri/src/cover_cache/mod.rs @@ -47,6 +47,49 @@ pub struct CoverCacheStatsDto { pub entry_count: u64, } +/// Live cover HTTP / WebP-encode slots — mirrors analysis pipeline probe shape. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CoverPipelineQueueStatsDto { + pub http_max: u32, + pub http_active: u32, + pub cpu_ui_max: u32, + pub cpu_ui_active: u32, + pub cpu_backfill_max: u32, + pub cpu_backfill_active: u32, + pub library_backfill_http_max: u32, + pub library_backfill_http_active: u32, + pub library_backfill_pass_running: bool, +} + +fn sem_active(sem: &Semaphore, max: u32) -> u32 { + max.saturating_sub(sem.available_permits() as u32) +} + +pub(crate) fn cover_pipeline_queue_stats( + cache: &CoverCacheState, + backfill: Option<&backfill_worker::CoverBackfillWorker>, +) -> CoverPipelineQueueStatsDto { + let (library_backfill_http_max, library_backfill_http_active, library_backfill_pass_running) = + backfill + .map(backfill_worker::CoverBackfillWorker::pipeline_http_stats) + .unwrap_or((0, 0, false)); + CoverPipelineQueueStatsDto { + http_max: COVER_HTTP_CONCURRENCY as u32, + http_active: sem_active(&cache.http_sem, COVER_HTTP_CONCURRENCY as u32), + cpu_ui_max: COVER_CPU_UI_CONCURRENCY as u32, + cpu_ui_active: sem_active(&cache.cover_cpu_ui_sem, COVER_CPU_UI_CONCURRENCY as u32), + cpu_backfill_max: COVER_CPU_BACKFILL_CONCURRENCY as u32, + cpu_backfill_active: sem_active( + &cache.cover_cpu_backfill_sem, + COVER_CPU_BACKFILL_CONCURRENCY as u32, + ), + library_backfill_http_max, + library_backfill_http_active, + library_backfill_pass_running, + } +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CoverCacheEnsureArgs { @@ -204,14 +247,15 @@ impl CoverCacheState { }; let dir_bg = dir.clone(); - let cover_cpu_sem_bg = cover_cpu_sem.clone(); let tiers_bg = tiers_now.clone(); + let cpu_permit = cover_cpu_sem + .clone() + .acquire_owned() + .await + .map_err(|e| e.to_string())?; let (mut wrote_requested, fresh_tiers) = tauri::async_runtime::spawn_blocking( move || -> Result<(bool, Vec<(u32, PathBuf)>), String> { - let rt = tokio::runtime::Handle::current(); - let _permit = rt - .block_on(cover_cpu_sem_bg.acquire()) - .map_err(|e| e.to_string())?; + let _cpu_permit = cpu_permit; let img = match source { CoverSource::Image(i) => i, CoverSource::Bytes(b) => decode_image_bytes(&b)?, @@ -374,11 +418,11 @@ fn spawn_derive_remaining_tiers( guard.cpu_sem_for(args.library_bulk), ) }; + let Ok(cpu_permit) = cover_cpu_sem.clone().acquire_owned().await else { + return; + }; let written = tauri::async_runtime::spawn_blocking(move || -> Vec<(u32, PathBuf)> { - let rt = tokio::runtime::Handle::current(); - let Ok(_permit) = rt.block_on(cover_cpu_sem.acquire()) else { - return Vec::new(); - }; + let _cpu_permit = cpu_permit; let mut fresh = Vec::new(); for tier in tiers_bg { if tier_exists(&dir, tier).is_some() { @@ -400,23 +444,9 @@ fn spawn_derive_remaining_tiers( } /// Entity dirs with canonical `800.webp` under `album/` and `artist/` (segment layout). +/// Per-server only — must not borrow counts from sibling buckets (multi-server UI stats). pub(crate) fn count_cached_cover_ids(root: &Path, server_index_key: &str) -> i64 { - let keyed = count_entities_with_canonical_tier(&cover_server_dir(root, server_index_key)); - if keyed > 0 { - return keyed; - } - // Host alias / legacy bucket name — pick the best segment count among siblings. - let Ok(entries) = std::fs::read_dir(root) else { - return 0; - }; - entries - .flatten() - .filter(|e| { - e.path().is_dir() && e.file_name().to_string_lossy() != ".storage-layout" - }) - .map(|e| count_entities_with_canonical_tier(&e.path())) - .max() - .unwrap_or(0) + count_entities_with_canonical_tier(&cover_server_dir(root, server_index_key)) } pub(crate) fn dir_usage_for_server(root: &Path, server_index_key: &str) -> (u64, u64) { @@ -687,6 +717,19 @@ pub async fn cover_cache_stats_server( }) } +#[tauri::command] +pub async fn cover_cache_get_pipeline_queue_stats( + app: AppHandle, +) -> Result { + let st = state(&app)?; + let guard = st.lock().await; + let backfill = app.try_state::>(); + Ok(cover_pipeline_queue_stats( + &guard, + backfill.as_ref().map(|w| w.as_ref()), + )) +} + #[tauri::command] pub async fn cover_cache_clear_server( app: AppHandle, @@ -979,10 +1022,26 @@ mod tests { use super::decode_image_bytes; use super::disk::{cover_dir, tier_path}; - use super::{is_safe_index_key, merge_cover_bucket, rename_bucket_inner}; + use super::{count_cached_cover_ids, is_safe_index_key, merge_cover_bucket, rename_bucket_inner}; + use psysonic_core::cover_cache_layout::CANONICAL_PROGRESS_TIER; use std::fs; use std::path::PathBuf; + #[test] + fn count_cached_cover_ids_is_per_server_bucket() { + let root = fresh_tmpdir("count-per-server"); + let home = cover_dir(&root, "music.home.example", "album", "al-home"); + fs::create_dir_all(&home).unwrap(); + fs::write( + home.join(format!("{CANONICAL_PROGRESS_TIER}.webp")), + b"x", + ) + .unwrap(); + assert_eq!(count_cached_cover_ids(&root, "music.home.example"), 1); + assert_eq!(count_cached_cover_ids(&root, "music.other.example"), 0); + let _ = fs::remove_dir_all(&root); + } + #[test] fn disk_layout_paths() { let root = std::path::Path::new("/tmp/cover-test"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 07f7cd19..493cce85 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -751,6 +751,7 @@ pub fn run() { cover_cache::cover_cache_clear_server, cover_cache::cover_cache_rename_server_bucket, cover_cache::cover_cache_stats_server, + cover_cache::cover_cache_get_pipeline_queue_stats, cover_cache::library_cover_backfill_batch, cover_cache::library_cover_progress, cover_cache::library_cover_catalog_size, diff --git a/src-tauri/src/lib_commands/app_api/perf.rs b/src-tauri/src/lib_commands/app_api/perf.rs index fea6ca32..115e470f 100644 --- a/src-tauri/src/lib_commands/app_api/perf.rs +++ b/src-tauri/src/lib_commands/app_api/perf.rs @@ -1,12 +1,31 @@ -//! Performance telemetry: process-level CPU snapshot for the Linux /proc -//! parser. Other platforms return `supported: false` — the frontend treats -//! that as "perf overlay not available" and hides itself. +//! Performance telemetry: process-level CPU + RSS and in-process thread CPU +//! groups. Linux uses `/proc`; macOS uses `sysinfo`. Other platforms return +//! `supported: false`. use serde::Serialize; +#[cfg(target_os = "linux")] +use std::collections::HashMap; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use std::sync::Mutex; #[cfg(target_os = "linux")] use std::fs; +const CHILD_RESCAN_EVERY: u8 = 8; + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct PerfProcessMemory { + pub label: String, + pub rss_kb: u64, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct PerfThreadCpuGroup { + pub label: String, + pub thread_count: u32, + pub jiffies: u64, +} + #[derive(Debug, Clone, Serialize)] pub(crate) struct PerformanceCpuSnapshot { pub supported: bool, @@ -14,6 +33,8 @@ pub(crate) struct PerformanceCpuSnapshot { pub app_jiffies: u64, pub webkit_jiffies: u64, pub logical_cpus: u32, + pub memory: Vec, + pub thread_cpu_groups: Vec, } #[cfg(target_os = "linux")] @@ -48,32 +69,423 @@ fn read_total_jiffies() -> Option { } #[cfg(target_os = "linux")] -fn collect_proc_stats() -> Vec<(i32, String, i32, u64)> { - let mut rows = Vec::new(); +fn read_status_rss_kb(path: &str) -> Option { + let content = fs::read_to_string(path).ok()?; + for line in content.lines() { + if let Some(kb) = line.strip_prefix("VmRSS:") { + return kb.split_whitespace().next()?.parse().ok(); + } + } + None +} + +#[cfg(target_os = "linux")] +fn proc_exists(pid: i32) -> bool { + fs::metadata(format!("/proc/{pid}")).is_ok() +} + +#[cfg(target_os = "linux")] +fn read_proc_stat_row(pid: i32) -> Option<(i32, String, i32, u64)> { + let stat_line = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + let (comm, ppid, utime, stime) = parse_proc_stat_line(stat_line.trim())?; + Some((pid, comm, ppid, utime.saturating_add(stime))) +} + +#[cfg(target_os = "linux")] +fn scan_child_pids(self_pid: i32) -> Vec { + let mut out = Vec::new(); let entries = match fs::read_dir("/proc") { Ok(v) => v, - Err(_) => return rows, + Err(_) => return out, }; for entry in entries.flatten() { - let name = entry.file_name(); - let pid = match name.to_string_lossy().parse::() { + let pid = match entry.file_name().to_string_lossy().parse::() { Ok(v) => v, Err(_) => continue, }; - let stat_path = format!("/proc/{pid}/stat"); - let stat_line = match fs::read_to_string(stat_path) { - Ok(v) => v, - Err(_) => continue, + if pid == self_pid { + continue; + } + let Some((_, _, ppid, _)) = read_proc_stat_row(pid) else { + continue; }; - if let Some((comm, ppid, utime, stime)) = parse_proc_stat_line(stat_line.trim()) { - rows.push((pid, comm, ppid, utime.saturating_add(stime))); + if ppid == self_pid { + out.push(pid); + } + } + out +} + +#[cfg(target_os = "linux")] +struct ChildPidCache { + child_pids: Vec, + ticks_until_rescan: u8, +} + +#[cfg(target_os = "linux")] +impl ChildPidCache { + fn refresh(&mut self, self_pid: i32) { + let stale = self + .child_pids + .iter() + .any(|pid| !proc_exists(*pid)); + if self.ticks_until_rescan == 0 || stale { + self.child_pids = scan_child_pids(self_pid); + self.ticks_until_rescan = CHILD_RESCAN_EVERY; + } else { + self.ticks_until_rescan -= 1; + } + } +} + +#[cfg(target_os = "linux")] +fn linux_child_cache() -> std::sync::MutexGuard<'static, ChildPidCache> { + static CACHE: Mutex = Mutex::new(ChildPidCache { + child_pids: Vec::new(), + ticks_until_rescan: 0, + }); + CACHE.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +#[cfg(target_os = "linux")] +fn collect_relevant_proc_stats(self_pid: i32) -> Vec<(i32, String, i32, u64)> { + let mut rows = Vec::new(); + if let Some(row) = read_proc_stat_row(self_pid) { + rows.push(row); + } + let child_pids = { + let mut cache = linux_child_cache(); + cache.refresh(self_pid); + cache.child_pids.clone() + }; + for child_pid in child_pids { + let Some(row) = read_proc_stat_row(child_pid) else { + continue; + }; + if row.2 == self_pid { + rows.push(row); } } rows } +/// Map a child process name to a stable perf-probe label (Linux `comm` or macOS name). +#[cfg(any(test, target_os = "linux", target_os = "macos"))] +fn child_process_memory_label(name: &str) -> &'static str { + let lower = name.to_ascii_lowercase(); + if lower.contains("webkitwebproces") || lower.contains("web content") || lower.contains("webcontent") { + "WebKit web" + } else if lower.contains("webkitnetwork") || (lower.contains("webkit") && lower.contains("network")) { + "WebKit network" + } else if lower.contains("webkitwebgp") || lower.contains("webkitgpuproc") || (lower.contains("webkit") && lower.contains("gpu")) { + "WebKit GPU" + } else if lower.contains("webkit") { + "WebKit other" + } else { + "other child" + } +} + +/// Group in-process thread names for CPU attribution (`feat/rust-thread-names` uses `psy-*`). +#[cfg(any(test, target_os = "linux"))] +fn thread_cpu_group_label(comm: &str) -> String { + // Tauri default: `tokio-rt-worker`; `feat/rust-thread-names`: `psy-tokio-N`. + if comm.starts_with("tokio-") || comm.starts_with("psy-tokio") { + return "tokio".to_string(); + } + if comm.starts_with("psy-") { + return comm.to_string(); + } + if comm.starts_with("psysonic-") { + return comm.to_string(); + } + if comm == "psysonic" { + return "psysonic".to_string(); + } + if comm.starts_with("pool") { + return "blocking-pool".to_string(); + } + if matches!(comm, "gmain" | "gdbus" | "dconf worker") { + return "glib".to_string(); + } + if comm.starts_with("cpal_") + || comm.starts_with("alsa-") + || comm == "module-rt" + || comm.starts_with("data-loop") + { + return "audio/pipewire".to_string(); + } + if comm.starts_with("reqwest-") { + return "reqwest".to_string(); + } + if comm.starts_with("async-io") || comm.starts_with("zbus::") { + return "async-io".to_string(); + } + "other".to_string() +} + +#[cfg(target_os = "linux")] +fn collect_task_cpu_groups(pid: i32) -> Vec { + let task_root = format!("/proc/{pid}/task"); + let entries = match fs::read_dir(&task_root) { + Ok(v) => v, + Err(_) => return Vec::new(), + }; + let mut groups: HashMap = HashMap::new(); + for entry in entries.flatten() { + let tid = entry.file_name(); + let stat_path = task_root.clone() + "/" + &tid.to_string_lossy() + "/stat"; + let stat_line = match fs::read_to_string(&stat_path) { + Ok(v) => v, + Err(_) => continue, + }; + let Some((comm, _, utime, stime)) = parse_proc_stat_line(stat_line.trim()) else { + continue; + }; + let label = thread_cpu_group_label(&comm); + let entry = groups.entry(label).or_insert((0, 0)); + entry.0 += 1; + entry.1 = entry.1.saturating_add(utime.saturating_add(stime)); + } + let mut out: Vec = groups + .into_iter() + .map(|(label, (thread_count, jiffies))| PerfThreadCpuGroup { + label, + thread_count, + jiffies, + }) + .collect(); + out.sort_by(|a, b| b.jiffies.cmp(&a.jiffies).then_with(|| a.label.cmp(&b.label))); + out +} + +#[cfg(target_os = "linux")] +fn collect_process_memory(pid: i32, rows: &[(i32, String, i32, u64)], self_pid: i32) -> Vec { + let mut groups: HashMap<&'static str, u64> = HashMap::new(); + if let Some(rss) = read_status_rss_kb(&format!("/proc/{pid}/status")) { + groups.insert("psysonic", rss); + } + for (child_pid, comm, ppid, _) in rows { + if *ppid != self_pid || *child_pid == self_pid { + continue; + } + let Some(rss) = read_status_rss_kb(&format!("/proc/{child_pid}/status")) else { + continue; + }; + let label = child_process_memory_label(comm); + let entry = groups.entry(label).or_insert(0); + *entry = entry.saturating_add(rss); + } + let order = [ + "psysonic", + "WebKit web", + "WebKit network", + "WebKit GPU", + "WebKit other", + "other child", + ]; + let mut out: Vec = groups + .into_iter() + .map(|(label, rss_kb)| PerfProcessMemory { + label: label.to_string(), + rss_kb, + }) + .collect(); + out.sort_by(|a, b| { + let ai = order.iter().position(|&x| x == a.label).unwrap_or(order.len()); + let bi = order.iter().position(|&x| x == b.label).unwrap_or(order.len()); + ai.cmp(&bi).then_with(|| b.rss_kb.cmp(&a.rss_kb)) + }); + out +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +fn empty_snapshot() -> PerformanceCpuSnapshot { + PerformanceCpuSnapshot { + supported: false, + total_jiffies: 0, + app_jiffies: 0, + webkit_jiffies: 0, + logical_cpus: 1, + memory: Vec::new(), + thread_cpu_groups: Vec::new(), + } +} + +#[cfg(target_os = "macos")] +mod macos { + use super::{ + child_process_memory_label, PerformanceCpuSnapshot, PerfProcessMemory, CHILD_RESCAN_EVERY, + }; + use std::collections::HashMap; + use std::mem; + use std::sync::Mutex; + use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System}; + + struct ChildPidCache { + child_pids: Vec, + ticks_until_rescan: u8, + } + + impl ChildPidCache { + fn refresh(&mut self, sys: &mut System, self_pid: Pid) { + let stale = self + .child_pids + .iter() + .any(|pid| sys.process(*pid).is_none()); + if self.ticks_until_rescan == 0 || stale { + sys.refresh_processes_specifics( + ProcessesToUpdate::All, + false, + ProcessRefreshKind::nothing().with_cpu().with_memory(), + ); + self.child_pids = sys + .processes() + .iter() + .filter_map(|(pid, process)| { + if process.parent() == Some(self_pid) { + Some(*pid) + } else { + None + } + }) + .collect(); + self.ticks_until_rescan = CHILD_RESCAN_EVERY; + } else { + self.ticks_until_rescan -= 1; + } + } + } + + static SYSTEM: Mutex> = Mutex::new(None); + + fn child_cache() -> std::sync::MutexGuard<'static, ChildPidCache> { + static CACHE: Mutex = Mutex::new(ChildPidCache { + child_pids: Vec::new(), + ticks_until_rescan: 0, + }); + CACHE.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + fn read_host_total_cpu_ticks() -> u64 { + let mut mib = [libc::CTL_KERN, libc::KERN_CP_TIME]; + let mut cp_time = [0_u64; libc::CPUSTATES as usize]; + let mut size = mem::size_of_val(&cp_time); + let ok = unsafe { + libc::sysctl( + mib.as_mut_ptr(), + mib.len() as _, + cp_time.as_mut_ptr() as *mut _, + &mut size, + std::ptr::null_mut(), + 0, + ) == 0 + }; + if ok { + cp_time.iter().sum() + } else { + 0 + } + } + + fn refresh_target_processes(sys: &mut System, self_pid: Pid) -> Vec { + let child_pids = { + let mut cache = child_cache(); + cache.refresh(sys, self_pid); + cache.child_pids.clone() + }; + let mut target = Vec::with_capacity(1 + child_pids.len()); + target.push(self_pid); + target.extend(child_pids); + sys.refresh_processes_specifics( + ProcessesToUpdate::Some(&target), + false, + ProcessRefreshKind::nothing().with_cpu().with_memory(), + ); + target + } + + fn is_webkit_web_cpu_process(name: &str) -> bool { + child_process_memory_label(name) == "WebKit web" + } + + fn collect_process_memory(sys: &System, self_pid: Pid, child_pids: &[Pid]) -> Vec { + let mut groups: HashMap<&'static str, u64> = HashMap::new(); + if let Some(process) = sys.process(self_pid) { + groups.insert("psysonic", process.memory() / 1024); + } + for child_pid in child_pids { + if *child_pid == self_pid { + continue; + } + let Some(process) = sys.process(*child_pid) else { + continue; + }; + let name = process.name().to_string_lossy(); + let label = child_process_memory_label(&name); + let entry = groups.entry(label).or_insert(0); + *entry = entry.saturating_add(process.memory() / 1024); + } + let order = [ + "psysonic", + "WebKit web", + "WebKit network", + "WebKit GPU", + "WebKit other", + "other child", + ]; + let mut out: Vec = groups + .into_iter() + .map(|(label, rss_kb)| PerfProcessMemory { + label: label.to_string(), + rss_kb, + }) + .collect(); + out.sort_by(|a, b| { + let ai = order.iter().position(|&x| x == a.label).unwrap_or(order.len()); + let bi = order.iter().position(|&x| x == b.label).unwrap_or(order.len()); + ai.cmp(&bi).then_with(|| b.rss_kb.cmp(&a.rss_kb)) + }); + out + } + + pub(super) fn performance_cpu_snapshot(_include_thread_groups: bool) -> PerformanceCpuSnapshot { + let mut guard = SYSTEM.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + if guard.is_none() { + *guard = Some(System::new()); + } + let sys = guard.as_mut().unwrap(); + let self_pid = Pid::from_u32(std::process::id()); + let child_pids = refresh_target_processes(sys, self_pid); + let logical_cpus = std::thread::available_parallelism() + .map(|n| n.get() as u32) + .unwrap_or(1); + let total_jiffies = read_host_total_cpu_ticks(); + let app_jiffies = sys + .process(self_pid) + .map(|process| process.accumulated_cpu_time()) + .unwrap_or(0); + let webkit_jiffies: u64 = child_pids + .iter() + .filter_map(|pid| sys.process(*pid)) + .filter(|process| is_webkit_web_cpu_process(&process.name().to_string_lossy())) + .map(|process| process.accumulated_cpu_time()) + .sum(); + PerformanceCpuSnapshot { + supported: true, + total_jiffies, + app_jiffies, + webkit_jiffies, + logical_cpus, + memory: collect_process_memory(sys, self_pid, &child_pids), + thread_cpu_groups: Vec::new(), + } + } +} + #[tauri::command] -pub(crate) fn performance_cpu_snapshot() -> PerformanceCpuSnapshot { +pub(crate) fn performance_cpu_snapshot(include_thread_groups: Option) -> PerformanceCpuSnapshot { + let include_thread_groups = include_thread_groups.unwrap_or(false); #[cfg(target_os = "linux")] { let total_jiffies = read_total_jiffies().unwrap_or(0); @@ -81,7 +493,7 @@ pub(crate) fn performance_cpu_snapshot() -> PerformanceCpuSnapshot { .map(|n| n.get() as u32) .unwrap_or(1); let self_pid = std::process::id() as i32; - let rows = collect_proc_stats(); + let rows = collect_relevant_proc_stats(self_pid); let app_jiffies = rows .iter() .find(|(pid, _, _, _)| *pid == self_pid) @@ -100,16 +512,65 @@ pub(crate) fn performance_cpu_snapshot() -> PerformanceCpuSnapshot { app_jiffies, webkit_jiffies, logical_cpus, + memory: collect_process_memory(self_pid, &rows, self_pid), + thread_cpu_groups: if include_thread_groups { + collect_task_cpu_groups(self_pid) + } else { + Vec::new() + }, } } - #[cfg(not(target_os = "linux"))] + #[cfg(target_os = "macos")] { - PerformanceCpuSnapshot { - supported: false, - total_jiffies: 0, - app_jiffies: 0, - webkit_jiffies: 0, - logical_cpus: 1, - } + macos::performance_cpu_snapshot(include_thread_groups) + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + let _ = include_thread_groups; + empty_snapshot() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn thread_cpu_group_label_tokio_and_named() { + assert_eq!(thread_cpu_group_label("psy-tokio-3"), "tokio"); + assert_eq!(thread_cpu_group_label("tokio-runtime-w"), "tokio"); + assert_eq!(thread_cpu_group_label("tokio-rt-worker"), "tokio"); + assert_eq!(thread_cpu_group_label("psy-audio-out"), "psy-audio-out"); + assert_eq!(thread_cpu_group_label("psy-decode"), "psy-decode"); + assert_eq!( + thread_cpu_group_label("psysonic-audio-"), + "psysonic-audio-" + ); + assert_eq!(thread_cpu_group_label("pool-1"), "blocking-pool"); + assert_eq!(thread_cpu_group_label("gmain"), "glib"); + assert_eq!(thread_cpu_group_label("cpal_alsa_out"), "audio/pipewire"); + assert_eq!(thread_cpu_group_label("reqwest-interna"), "reqwest"); + assert_eq!(thread_cpu_group_label("rustc"), "other"); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn child_process_memory_label_webkit_names() { + assert_eq!( + child_process_memory_label("WebKitWebProces"), + "WebKit web" + ); + assert_eq!( + child_process_memory_label("WebKitNetworkP"), + "WebKit network" + ); + assert_eq!( + child_process_memory_label("com.apple.WebKit.WebContent.xpc"), + "WebKit web" + ); + assert_eq!( + child_process_memory_label("WebKit Networking"), + "WebKit network" + ); } } diff --git a/src/api/coverCache.ts b/src/api/coverCache.ts index b86f22b5..036a2790 100644 --- a/src/api/coverCache.ts +++ b/src/api/coverCache.ts @@ -33,6 +33,18 @@ export type CoverCacheStats = { entryCount: number; }; +export type CoverPipelineQueueStatsDto = { + httpMax: number; + httpActive: number; + cpuUiMax: number; + cpuUiActive: number; + cpuBackfillMax: number; + cpuBackfillActive: number; + libraryBackfillHttpMax: number; + libraryBackfillHttpActive: number; + libraryBackfillPassRunning: boolean; +}; + let coverAutoDownloadEnabled = true; export function setCoverCacheAutoDownloadEnabled(enabled: boolean): void { @@ -150,6 +162,10 @@ export async function coverCacheStatsServer( return { bytes: stats.bytes, entryCount: stats.entryCount }; } +export function coverGetPipelineQueueStats(): Promise { + return invoke('cover_cache_get_pipeline_queue_stats'); +} + export async function libraryCoverBackfillBatch( serverIndexKey: string, libraryServerId: string, diff --git a/src/components/FpsOverlay.tsx b/src/components/FpsOverlay.tsx index 6a6a7828..74efe20b 100644 --- a/src/components/FpsOverlay.tsx +++ b/src/components/FpsOverlay.tsx @@ -1,29 +1,111 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; import { createPortal } from 'react-dom'; import { analysisGetPipelineQueueStats, type AnalysisPipelineQueueStatsDto } from '../api/analysis'; -import { usePerfProbeFlag } from '../utils/perf/perfFlags'; +import { coverGetPipelineQueueStats, type CoverPipelineQueueStatsDto } from '../api/coverCache'; +import { coverEnsureQueueStats } from '../cover/ensureQueue'; +import { coverPeekQueueStats } from '../cover/peekQueue'; +import PerfOverlaySparkline from './perf/PerfOverlaySparkline'; +import { usePerfProbeFlags } from '../utils/perf/perfFlags'; import { formatPerfMs, getAnalysisTracksPerMinute, useAnalysisPerfLast, } from '../utils/perf/analysisPerfStore'; import { formatAnalysisPipelineQueueOverlay } from '../utils/perf/formatAnalysisQueueStats'; +import { formatCoverPipelineQueueOverlay } from '../utils/perf/formatCoverPipelineQueueOverlay'; +import { + buildLiveOverlayItems, + type LiveOverlayItem, +} from '../utils/perf/formatLiveOverlayItems'; +import { + getPerfLiveHistoryClock, + syncPerfLiveHistoryFromPoll, + usePerfLiveHistorySamples, +} from '../utils/perf/perfLiveHistory'; +import { acquirePerfLivePoll, usePerfLiveSnapshot } from '../utils/perf/perfLiveStore'; +import { hasAnyLiveMetricPollNeed, usePerfLiveOverlayPins } from '../utils/perf/perfOverlayPins'; +import { + perfOverlayCornerClass, + usePerfOverlayAppearance, +} from '../utils/perf/perfOverlayAppearance'; +import { + resolveOverlayVisibility, + usePerfOverlayMode, +} from '../utils/perf/perfOverlayMode'; import { useAnalysisPerfListener } from '../hooks/useAnalysisPerfListener'; const SAMPLE_MS = 500; const TPM_REFRESH_MS = 500; const QUEUE_STATS_MS = 750; -/** FPS + analysis throughput overlay (Performance Probe). */ +function LiveOverlayPinnedMetric({ + item, + now, +}: { + item: LiveOverlayItem; + now: number; +}) { + const history = usePerfLiveHistorySamples(item.id); + const sparklineKind = item.kind === 'memory' ? 'memory' : 'cpu'; + + return ( +
+
{item.line}
+ {item.sparkline && ( + + )} +
+ ); +} + +/** FPS + pipeline + pinned live metrics overlay (Performance Probe). */ export default function FpsOverlay() { - const showFpsOverlay = usePerfProbeFlag('showFpsOverlay'); - const showAnalysisPerfOverlay = usePerfProbeFlag('showAnalysisPerfOverlay'); + const overlayMode = usePerfOverlayMode(); + const perfFlags = usePerfProbeFlags(); + const livePins = usePerfLiveOverlayPins(); + const live = usePerfLiveSnapshot(); + const overlayAppearance = usePerfOverlayAppearance(); const [fps, setFps] = useState(0); const [tpm, setTpm] = useState(0); const [queueStats, setQueueStats] = useState(null); + const [coverQueueLines, setCoverQueueLines] = useState([]); const last = useAnalysisPerfLast(); + const lastHistoryAt = useRef(0); - useAnalysisPerfListener(showAnalysisPerfOverlay); + const liveOverlayItems = useMemo( + () => buildLiveOverlayItems(livePins, live), + [livePins, live], + ); + + const visibility = useMemo( + () => resolveOverlayVisibility(overlayMode, perfFlags, liveOverlayItems.length), + [overlayMode, perfFlags, liveOverlayItems.length], + ); + + const { + showFps: showFpsOverlay, + showAnalysis: showAnalysisPerfOverlay, + showCover: showCoverPerfOverlay, + showLive, + } = visibility; + + lastHistoryAt.current = overlayMode === 'pinned' + ? syncPerfLiveHistoryFromPoll(livePins, live, lastHistoryAt.current) + : lastHistoryAt.current; + + const sparklineNow = useMemo(() => { + const clock = getPerfLiveHistoryClock( + liveOverlayItems.filter(item => item.sparkline).map(item => item.id), + ); + return clock > 0 ? clock : Date.now(); + }, [liveOverlayItems, live.updatedAt]); + + useAnalysisPerfListener(showAnalysisPerfOverlay || livePins.has('analysis:tpm') || livePins.has('analysis:last')); + + useEffect(() => { + if (overlayMode !== 'pinned' || !hasAnyLiveMetricPollNeed()) return; + return acquirePerfLivePoll('overlay-pins'); + }, [overlayMode, livePins.size]); useEffect(() => { if (!showAnalysisPerfOverlay) { @@ -59,6 +141,36 @@ export default function FpsOverlay() { }; }, [showAnalysisPerfOverlay]); + useEffect(() => { + if (!showCoverPerfOverlay) { + setCoverQueueLines([]); + return; + } + let cancelled = false; + const refresh = () => { + void coverGetPipelineQueueStats() + .then((rust: CoverPipelineQueueStatsDto) => { + if (cancelled) return; + setCoverQueueLines( + formatCoverPipelineQueueOverlay({ + rust, + ensure: coverEnsureQueueStats(), + peek: coverPeekQueueStats(), + }), + ); + }) + .catch(() => { + if (!cancelled) setCoverQueueLines([]); + }); + }; + refresh(); + const id = window.setInterval(refresh, QUEUE_STATS_MS); + return () => { + cancelled = true; + window.clearInterval(id); + }; + }, [showCoverPerfOverlay]); + useEffect(() => { if (!showFpsOverlay) { setFps(0); @@ -85,19 +197,35 @@ export default function FpsOverlay() { return () => cancelAnimationFrame(rafId); }, [showFpsOverlay]); - if (!showFpsOverlay && !showAnalysisPerfOverlay) return null; + if (overlayMode === 'off') return null; + if (!showFpsOverlay && !showAnalysisPerfOverlay && !showCoverPerfOverlay && !showLive) return null; + + const analysisQueueLines = queueStats ? formatAnalysisPipelineQueueOverlay(queueStats) : []; return createPortal( -