From 9925771a8602040c9b092321ebc33f44a3c0cb8f Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Fri, 29 May 2026 04:40:31 +0300 Subject: [PATCH] feat(browse,cover,perf): lazy catalogs, cover pipeline, and Performance Probe (#890) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cover): per-server cache stats and cover pipeline perf probe Stop count_cached_cover_ids from borrowing sibling bucket counts so Settings progress no longer attributes one server's disk cache to another. Add cover pipeline queue stats (ui ensure queue, ui vs lib HTTP/WebP semaphores) to Performance Probe overlay, with clearer ui/lib labels. * fix(browse): stabilize in-page infinite scroll and cap cover memory caches Extract useInpageScrollSentinel for album grids and song lists so sentinel reconnects do not spam loadMore during scroll. Harden useAlbumBrowseData with sync loading refs, tighter root margin, and hasMore termination when dedupe adds nothing. Pause middle-priority cover work during SQL pagination and bound diskSrc/resolve/ensure tail maps on long cold-cache sessions. * refactor(browse): unify in-page infinite scroll hooks and sentinel UI Extract shared transport (viewport ref, async pagination guards, client slice) and InpageScrollSentinel so Albums, New Releases, Artists, and song lists use one pagination pattern instead of duplicated IntersectionObserver wiring. * fix(browse): prioritize album SQL pagination over cover ensures Pause the entire webview ensure pump during grid page fetches, resume after SQL settles, add cover-queue backpressure before load-more, and re-probe the sentinel when pagination finishes so cold-cache scroll does not stall. * fix(browse): unblock covers, SQL spawn_blocking, and pagination retry Pair grid-pagination hold begin/end on stale fetches, resume the ensure pump after SQL, retry load-more when the cover backlog drains while the sentinel stays visible, and run album browse SQL on spawn_blocking so Tokio stays responsive during library_advanced_search. * feat(browse): All Albums client-slice scroll on local index (Artists-style) Load the filtered catalog once from SQLite when the library index is ready, then grow the visible grid with useClientSliceInfiniteScroll instead of offset SQL pagination per scroll. Network-only servers keep page mode. * fix(browse): lazy local catalog chunks instead of full 50k SQL fetch All Albums slice mode now loads 200 albums first, shows the grid immediately, then appends catalog chunks in the background as the user scrolls. Avoids the blocking library_advanced_search that hung the app on large libraries. * fix(browse): keep album covers loading during active grid scroll Pass high ensure priority and the in-page scroll root to AlbumCard on All Albums, stop pausing cover traffic for background catalog chunks, and never trim high-priority ensure jobs from the queue during scroll bursts. * fix(cover): viewport priority tiers and unstick ensure invoke pump All Albums uses IO-driven high/middle instead of blanket high; release only on unmount so scroll-ahead jobs are not dropped on reprioritize. Ensure queue shares one Rust flight per cover id, attaches duplicate waiters without consuming invoke slots, and times out wedged calls. Warm the first viewport slice on large grids; acquire CPU permits before spawn_blocking in cover_cache to avoid blocking-thread deadlocks. * fix(cover): wire in-page scroll root on New Releases and Lossless grids AlbumCard IO uses the same viewport id as VirtualCardGrid so cover ensure priority tracks visible in-page rows like All Albums. * fix(browse): lazy local artist catalog in 200-row chunks Replace runLocalBrowseAllArtists bulk fetch with paginated local-index chunks so large libraries do not hang on open; preserve text search, starred, letter filter, and client-slice scroll behavior. * feat(perf): add RSS and thread CPU groups to Performance Probe Extend performance_cpu_snapshot with process RSS (psysonic + WebKit children) and in-process thread CPU breakdown. Classify tokio-rt-worker and tokio-* workers separately from glib, audio/pipewire, reqwest, and other misc threads (Linux /proc only). * feat(perf): redesign Performance Probe with tabs, pins, and overlay layout Split the probe into Monitor (live metric cards, per-metric overlay pins, corner and opacity controls) and Toggles (diagnostic tree). Share live polling via perfLiveStore; label analysis/cover pipeline blocks in the HUD. * feat(perf): overlay sparklines, macOS CPU/memory, and sync fixes Add 1-minute pinned-metric sparklines with right-aligned growth and a shared poll clock. Enable macOS performance snapshots via sysinfo. Fix overlay infinite loop from unstable history snapshots, bar/sparkline tick jitter, and probe bar rescale flicker. * docs: CHANGELOG and credits for PR #890 * perf(probe): scoped CPU poll, adjustable interval, lazy thread groups Read only psysonic + WebKit children instead of the full process table; macOS uses sysctl host CPU and refreshes cached child PIDs. Add 0.5–10s poll slider (default 2s). Collect /proc thread groups only when the Monitor section is open or a thread metric is pinned. * feat(perf): three-way overlay mode switch (off / FPS / pinned) Add Monitor control for overlay visibility: hidden, FPS-only, or pinned metrics from Monitor. Live CPU poll runs only in pinned mode with live pins. --- CHANGELOG.md | 31 + src-tauri/Cargo.lock | 11 + src-tauri/Cargo.toml | 2 +- .../crates/psysonic-library/src/commands.rs | 17 +- src-tauri/src/cover_cache/backfill_worker.rs | 8 + src-tauri/src/cover_cache/mod.rs | 111 +++- src-tauri/src/lib.rs | 1 + src-tauri/src/lib_commands/app_api/perf.rs | 509 +++++++++++++++- src/api/coverCache.ts | 16 + src/components/FpsOverlay.tsx | 164 +++++- src/components/InpageScrollSentinel.tsx | 27 + src/components/PagedSongList.tsx | 31 +- src/components/Sidebar.tsx | 5 +- src/components/perf/PerfOverlaySparkline.tsx | 130 +++++ .../sidebar/SidebarPerfProbeModal.tsx | 363 ++++-------- .../sidebar/SidebarPerfProbePhase2.tsx | 176 ------ .../perfProbe/PerfLivePollControls.tsx | 33 ++ .../PerfOverlayAppearanceControls.tsx | 48 ++ .../perfProbe/PerfOverlayModeControls.tsx | 33 ++ .../sidebar/perfProbe/PerfProbeMetricCard.tsx | 98 ++++ .../perfProbe/SidebarPerfProbeMonitorTab.tsx | 230 ++++++++ .../perfProbe/SidebarPerfProbeTogglesTab.tsx | 165 ++++++ src/config/settingsCredits.ts | 2 + src/cover/CoverArtImage.tsx | 83 ++- src/cover/coverTraffic.test.ts | 27 + src/cover/coverTraffic.ts | 19 +- src/cover/diskSrcCache.ts | 13 +- src/cover/ensureQueue.test.ts | 54 +- src/cover/ensureQueue.ts | 195 +++++-- src/cover/peekQueue.ts | 10 + src/cover/resolveEntryLibrary.ts | 11 + src/cover/useCoverArt.ts | 16 +- src/cover/warmDiskPeek.ts | 11 +- src/hooks/useAlbumBrowseData.test.ts | 28 + src/hooks/useAlbumBrowseData.ts | 304 +++++++++- src/hooks/useArtistsBrowseCatalog.ts | 130 +++++ src/hooks/useArtistsInfiniteScroll.ts | 96 +-- src/hooks/useAsyncInpagePagination.test.ts | 47 ++ src/hooks/useAsyncInpagePagination.ts | 73 +++ src/hooks/useAsyncInpageScroll.ts | 47 ++ .../useClientSliceInfiniteScroll.test.ts | 31 + src/hooks/useClientSliceInfiniteScroll.ts | 71 +++ src/hooks/useInpageScrollSentinel.test.ts | 16 + src/hooks/useInpageScrollSentinel.ts | 86 +++ src/hooks/useInpageScrollViewport.ts | 13 + src/hooks/useSidebarPerfProbe.ts | 187 ++---- src/hooks/useWarmGridCovers.ts | 12 +- src/pages/Albums.tsx | 74 ++- src/pages/Artists.tsx | 183 ++++-- src/pages/Composers.tsx | 59 +- src/pages/LosslessAlbums.tsx | 44 +- src/pages/NewReleases.tsx | 79 ++- src/styles/components/modal.css | 548 +++++++++++++++++- .../components/orbit-session-top-strip.css | 120 +++- src/utils/library/albumBrowseLoad.ts | 14 + src/utils/library/browseTextSearch.ts | 30 + .../formatCoverPipelineQueueOverlay.test.ts | 65 +++ .../perf/formatCoverPipelineQueueOverlay.ts | 32 + src/utils/perf/formatLiveOverlayItems.ts | 125 ++++ src/utils/perf/perfFlags.ts | 3 + src/utils/perf/perfLiveHistory.ts | 143 +++++ src/utils/perf/perfLivePollSettings.ts | 99 ++++ src/utils/perf/perfLiveStore.ts | 286 +++++++++ src/utils/perf/perfOverlayAppearance.ts | 107 ++++ src/utils/perf/perfOverlayMode.ts | 114 ++++ src/utils/perf/perfOverlayPins.ts | 132 +++++ src/utils/perf/perfProbeToggleTree.ts | 293 ++++++++++ 67 files changed, 5298 insertions(+), 1043 deletions(-) create mode 100644 src/components/InpageScrollSentinel.tsx create mode 100644 src/components/perf/PerfOverlaySparkline.tsx delete mode 100644 src/components/sidebar/SidebarPerfProbePhase2.tsx create mode 100644 src/components/sidebar/perfProbe/PerfLivePollControls.tsx create mode 100644 src/components/sidebar/perfProbe/PerfOverlayAppearanceControls.tsx create mode 100644 src/components/sidebar/perfProbe/PerfOverlayModeControls.tsx create mode 100644 src/components/sidebar/perfProbe/PerfProbeMetricCard.tsx create mode 100644 src/components/sidebar/perfProbe/SidebarPerfProbeMonitorTab.tsx create mode 100644 src/components/sidebar/perfProbe/SidebarPerfProbeTogglesTab.tsx create mode 100644 src/hooks/useAlbumBrowseData.test.ts create mode 100644 src/hooks/useArtistsBrowseCatalog.ts create mode 100644 src/hooks/useAsyncInpagePagination.test.ts create mode 100644 src/hooks/useAsyncInpagePagination.ts create mode 100644 src/hooks/useAsyncInpageScroll.ts create mode 100644 src/hooks/useClientSliceInfiniteScroll.test.ts create mode 100644 src/hooks/useClientSliceInfiniteScroll.ts create mode 100644 src/hooks/useInpageScrollSentinel.test.ts create mode 100644 src/hooks/useInpageScrollSentinel.ts create mode 100644 src/hooks/useInpageScrollViewport.ts create mode 100644 src/utils/perf/formatCoverPipelineQueueOverlay.test.ts create mode 100644 src/utils/perf/formatCoverPipelineQueueOverlay.ts create mode 100644 src/utils/perf/formatLiveOverlayItems.ts create mode 100644 src/utils/perf/perfLiveHistory.ts create mode 100644 src/utils/perf/perfLivePollSettings.ts create mode 100644 src/utils/perf/perfLiveStore.ts create mode 100644 src/utils/perf/perfOverlayAppearance.ts create mode 100644 src/utils/perf/perfOverlayMode.ts create mode 100644 src/utils/perf/perfOverlayPins.ts create mode 100644 src/utils/perf/perfProbeToggleTree.ts 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( -