From 08b6aeeb17d57aa7a4de74226ee7ce35fc4c49cb Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:50:17 +0300 Subject: [PATCH] fix(perf): reduce idle Rust CPU and stabilize Performance Probe overlay (#939) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(perf): skip Performance Probe CPU snapshot poll on Windows Windows has no Rust CPU/RSS sampler, but the probe still invoked performance_cpu_snapshot every 2s when the modal or overlay pins were active. Skip the IPC on unsupported platforms and only poll JS-side metrics; do not start overlay polling for CPU/memory pins alone. * fix(analysis): park backfill coordinator until Advanced is configured #881 started run_coordinator_forever at app init with a 2s sleep even when disabled, waking tokio on every platform for no work. Park on Notify instead; wake on configure (enable/disable) and library sync-idle. Long sleeps use select with wake so sync-idle can interrupt COMPLETED_RECHECK waits. Investigation branch — not for merge until periodic CPU root cause is confirmed. * fix(perf): stop probe overlay flicker on live poll updates Publish CPU samples only after a valid jiffies baseline, skip no-op snapshot emits, and sync sparkline history atomically in the store. Overlay uses wall-clock sparkline time and auto-scales low CPU values. * fix(perf): cut idle Rust CPU from probe scan, cover prefetch, and storage poll Move performance_cpu_snapshot /proc work to spawn_blocking so tokio workers are not charged with probe sampling. Stop lazy cover strategy from running route prefetch disk stats every 1.5s, and slow hot-cache size refresh on Settings → Storage to 15s. * fix(perf): stabilize probe sparkline clock between live poll ticks Track sampleAt separately from updatedAt so CPU rate history and overlay sparklines only advance on real % changes, not FPS re-renders or RSS-only poll ticks. Hold CPU sparkline Y scale with a peak ref to avoid scale jumps. * fix(cover): restore lazy route prefetch without idle disk stats poll Re-enable lazy cover registry warm-up so cached WebP paths reach diskSrcCache before cells mount. Skip cover_cache_stats on every 1.5s tick — drain batches via ensure only, poll full disk usage every 30s when the registry is idle. * docs: CHANGELOG and credits for PR #939 idle CPU perf fix * fix(cover): peek before route prefetch ensure to match main responsiveness Route prefetch moved batch drain ahead of cover_cache_stats for idle CPU, which removed the accidental throttle and flooded ensure invoke slots. Use warmCoverDiskSrcBatch first (cached hits skip ensure), ensure misses only, and yield while high-priority viewport work is queued. --- CHANGELOG.md | 11 + src-tauri/src/lib_commands/app_api/perf.rs | 10 +- .../src/library_analysis_backfill/worker.rs | 87 ++++--- src/components/FpsOverlay.tsx | 40 ++-- src/components/perf/PerfOverlaySparkline.tsx | 6 +- src/components/settings/StorageTab.tsx | 2 +- .../perfProbe/PerfLivePollControls.tsx | 3 + .../perfProbe/SidebarPerfProbeMonitorTab.tsx | 4 +- src/config/settingsCredits.ts | 1 + src/cover/useCoverArtPrefetch.ts | 73 ++++-- src/utils/perf/perfLiveCpuSnapshot.ts | 6 + src/utils/perf/perfLiveHistory.ts | 17 +- src/utils/perf/perfLiveStore.ts | 219 +++++++++++++----- src/utils/perf/perfOverlayPins.ts | 12 +- 14 files changed, 353 insertions(+), 138 deletions(-) create mode 100644 src/utils/perf/perfLiveCpuSnapshot.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index caaf46b3..f6c1b5b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -365,6 +365,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Fixed +### Performance — idle Rust CPU, probe overlay, and cover prefetch + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#939](https://github.com/Psychotoxical/psysonic/pull/939)** + +* **Advanced analytics coordinator:** park on `Notify` when disabled — no idle 2s poll loop; wake on configure or library sync-idle. +* **Performance Probe:** run CPU snapshot on the blocking pool; skip `/proc` poll on Windows; fix overlay flicker and sparkline clock jumps; hold previous CPU % until the first rate sample (no 0% flash). +* **Background polls:** Settings → Storage hot-cache poll 15s; cover registry full disk stats every 30s when idle instead of every 1.5s tick. +* **Cover art:** restore lazy route prefetch; batch disk peek before ensure so cached WebP warms `diskSrcCache` without flooding invoke slots; yield when viewport ensures are queued. + + + ### CI — npmDepsHash on app-v* tags **By [@cucadmuh](https://github.com/cucadmuh), PR [#927](https://github.com/Psychotoxical/psysonic/pull/927)** diff --git a/src-tauri/src/lib_commands/app_api/perf.rs b/src-tauri/src/lib_commands/app_api/perf.rs index bcf69744..82ac37b4 100644 --- a/src-tauri/src/lib_commands/app_api/perf.rs +++ b/src-tauri/src/lib_commands/app_api/perf.rs @@ -503,8 +503,16 @@ mod macos { } #[tauri::command] -pub(crate) fn performance_cpu_snapshot(include_thread_groups: Option) -> PerformanceCpuSnapshot { +pub(crate) async fn performance_cpu_snapshot( + include_thread_groups: Option, +) -> Result { let include_thread_groups = include_thread_groups.unwrap_or(false); + tauri::async_runtime::spawn_blocking(move || performance_cpu_snapshot_blocking(include_thread_groups)) + .await + .map_err(|e| e.to_string()) +} + +fn performance_cpu_snapshot_blocking(include_thread_groups: bool) -> PerformanceCpuSnapshot { #[cfg(target_os = "linux")] { let total_jiffies = read_total_jiffies().unwrap_or(0); diff --git a/src-tauri/src/library_analysis_backfill/worker.rs b/src-tauri/src/library_analysis_backfill/worker.rs index ef794315..f896faef 100644 --- a/src-tauri/src/library_analysis_backfill/worker.rs +++ b/src-tauri/src/library_analysis_backfill/worker.rs @@ -2,6 +2,9 @@ //! //! Replaces the webview `while` loop: top-up HTTP/CPU seed backlog without //! blocking the UI on `library_analysis_backfill_batch` IPC. +//! +//! The coordinator task **parks** on a `Notify` until Advanced analytics is +//! configured or library sync goes idle — no idle 2 s polling while disabled. use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -25,7 +28,7 @@ use psysonic_library::repos::TrackRepository; use psysonic_library::LibraryRuntime; use serde::Deserialize; use tauri::{AppHandle, Listener, Manager}; -use tokio::sync::Mutex; +use tokio::sync::{Mutex, Notify}; const TOP_UP_POLL_MS: u64 = 500; const STEADY_POLL_MS: u64 = 2000; @@ -48,6 +51,8 @@ pub struct LibraryAnalysisBackfillSession { pub struct LibraryAnalysisBackfillWorker { pub enabled: AtomicBool, + /// Wakes the coordinator task (configure, sync-idle, disable→enable). + wake: Notify, session: Mutex>, cursor: Mutex>, scan_phase: Mutex, @@ -59,6 +64,7 @@ impl LibraryAnalysisBackfillWorker { pub fn new() -> Self { Self { enabled: AtomicBool::new(false), + wake: Notify::new(), session: Mutex::new(None), cursor: Mutex::new(None), scan_phase: Mutex::new(AnalysisBackfillScanPhase::Candidates), @@ -67,6 +73,10 @@ impl LibraryAnalysisBackfillWorker { } } + fn ping_coordinator(&self) { + self.wake.notify_waiters(); + } + pub async fn set_session(&self, enabled: bool, session: Option) { self.enabled.store(enabled, Ordering::Relaxed); *self.session.lock().await = session; @@ -76,6 +86,7 @@ impl LibraryAnalysisBackfillWorker { *self.completed_total.lock().await = None; *self.exhausted_streak.lock().await = 0; } + self.ping_coordinator(); } } @@ -107,39 +118,64 @@ fn session_matches_server(session: &LibraryAnalysisBackfillSession, server_id: & server_id == session.server_index_key || server_id == session.library_server_id } -struct CoordinatorTick { - sleep_ms: u64, +enum CoordinatorStep { + Sleep(Duration), + /// Park until configure or sync-idle wakes the task (no idle polling). + Park, +} + +async fn coordinator_sleep(worker: &LibraryAnalysisBackfillWorker, duration: Duration) { + if duration.is_zero() { + return; + } + tokio::select! { + _ = tokio::time::sleep(duration) => {} + _ = worker.wake.notified() => {} + } } async fn run_coordinator_forever(app: AppHandle, worker: Arc) { loop { - let tick = coordinator_tick(&app, worker.as_ref()).await; - tokio::time::sleep(Duration::from_millis(tick.sleep_ms)).await; + while !worker.enabled.load(Ordering::Relaxed) { + worker.wake.notified().await; + } + + loop { + if !worker.enabled.load(Ordering::Relaxed) { + break; + } + match coordinator_tick(&app, worker.as_ref()).await { + CoordinatorStep::Park => break, + CoordinatorStep::Sleep(duration) => { + coordinator_sleep(worker.as_ref(), duration).await; + } + } + } } } async fn coordinator_tick( app: &AppHandle, worker: &LibraryAnalysisBackfillWorker, -) -> CoordinatorTick { +) -> CoordinatorStep { if !worker.enabled.load(Ordering::Relaxed) { - return CoordinatorTick { sleep_ms: STEADY_POLL_MS }; + return CoordinatorStep::Park; } let session = worker.session.lock().await.clone(); let Some(session) = session else { - return CoordinatorTick { sleep_ms: STEADY_POLL_MS }; + return CoordinatorStep::Park; }; if !session_still_focused(worker, &session).await { - return CoordinatorTick { sleep_ms: STEADY_POLL_MS }; + return CoordinatorStep::Park; } analysis_set_pipeline_parallelism(session.workers as usize); let runtime = match app.try_state::() { Some(r) => r, - None => return CoordinatorTick { sleep_ms: READY_POLL_MS }, + None => return CoordinatorStep::Sleep(Duration::from_millis(READY_POLL_MS)), }; if let Some(done_total) = *worker.completed_total.lock().await { @@ -155,9 +191,7 @@ async fn coordinator_tick( .and_then(|r| r.ok()) .unwrap_or(false); if still_same { - return CoordinatorTick { - sleep_ms: COMPLETED_RECHECK_MS, - }; + return CoordinatorStep::Sleep(Duration::from_millis(COMPLETED_RECHECK_MS)); } *worker.completed_total.lock().await = None; *worker.cursor.lock().await = None; @@ -167,9 +201,9 @@ async fn coordinator_tick( while sync_blocks_backfill(&runtime.store, &session.library_server_id) { if !worker.enabled.load(Ordering::Relaxed) { - return CoordinatorTick { sleep_ms: SYNC_WAIT_MS }; + return CoordinatorStep::Park; } - tokio::time::sleep(Duration::from_millis(SYNC_WAIT_MS)).await; + coordinator_sleep(worker, Duration::from_millis(SYNC_WAIT_MS)).await; } let store = runtime.store.clone(); @@ -180,7 +214,7 @@ async fn coordinator_tick( .and_then(|r| r.ok()) .unwrap_or(false); if !ready { - return CoordinatorTick { sleep_ms: READY_POLL_MS }; + return CoordinatorStep::Sleep(Duration::from_millis(READY_POLL_MS)); } let stats = analysis_pipeline_queue_stats(); @@ -192,12 +226,12 @@ async fn coordinator_tick( }; if !library_backfill_needs_top_up(counts, session.workers) { - return CoordinatorTick { sleep_ms: STEADY_POLL_MS }; + return CoordinatorStep::Sleep(Duration::from_millis(STEADY_POLL_MS)); } let fetch_limit = library_backfill_top_up_limit(counts, session.workers); if fetch_limit == 0 { - return CoordinatorTick { sleep_ms: TOP_UP_POLL_MS }; + return CoordinatorStep::Sleep(Duration::from_millis(TOP_UP_POLL_MS)); } let cursor = worker.cursor.lock().await.clone(); @@ -223,7 +257,7 @@ async fn coordinator_tick( .and_then(|r| r.ok()); let Some((batch, next_phase)) = batch else { - return CoordinatorTick { sleep_ms: TOP_UP_POLL_MS }; + return CoordinatorStep::Sleep(Duration::from_millis(TOP_UP_POLL_MS)); }; *worker.cursor.lock().await = batch.next_cursor.clone(); @@ -279,9 +313,7 @@ async fn coordinator_tick( } *worker.cursor.lock().await = None; *worker.scan_phase.lock().await = AnalysisBackfillScanPhase::Candidates; - return CoordinatorTick { - sleep_ms: COMPLETED_RECHECK_MS, - }; + return CoordinatorStep::Sleep(Duration::from_millis(COMPLETED_RECHECK_MS)); } } else { *worker.exhausted_streak.lock().await = 0; @@ -289,18 +321,14 @@ async fn coordinator_tick( if pending > 0 { *worker.cursor.lock().await = None; *worker.scan_phase.lock().await = AnalysisBackfillScanPhase::Candidates; - return CoordinatorTick { - sleep_ms: EXHAUSTED_PENDING_RESCAN_MS, - }; + return CoordinatorStep::Sleep(Duration::from_millis(EXHAUSTED_PENDING_RESCAN_MS)); } *worker.cursor.lock().await = None; *worker.scan_phase.lock().await = AnalysisBackfillScanPhase::Candidates; - return CoordinatorTick { - sleep_ms: EXHAUSTED_PAUSE_MS, - }; + return CoordinatorStep::Sleep(Duration::from_millis(EXHAUSTED_PAUSE_MS)); } - CoordinatorTick { sleep_ms: TOP_UP_POLL_MS } + CoordinatorStep::Sleep(Duration::from_millis(TOP_UP_POLL_MS)) } #[derive(Debug, Deserialize)] @@ -333,6 +361,7 @@ fn on_sync_idle(app: &AppHandle, payload: SyncIdlePayload) { *worker.cursor.lock().await = None; *worker.scan_phase.lock().await = AnalysisBackfillScanPhase::Candidates; *worker.exhausted_streak.lock().await = 0; + worker.ping_coordinator(); }); } diff --git a/src/components/FpsOverlay.tsx b/src/components/FpsOverlay.tsx index 74efe20b..9bfec8f2 100644 --- a/src/components/FpsOverlay.tsx +++ b/src/components/FpsOverlay.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; +import { useEffect, useMemo, useState, type CSSProperties } from 'react'; import { createPortal } from 'react-dom'; import { analysisGetPipelineQueueStats, type AnalysisPipelineQueueStatsDto } from '../api/analysis'; import { coverGetPipelineQueueStats, type CoverPipelineQueueStatsDto } from '../api/coverCache'; @@ -18,12 +18,10 @@ import { type LiveOverlayItem, } from '../utils/perf/formatLiveOverlayItems'; import { - getPerfLiveHistoryClock, - syncPerfLiveHistoryFromPoll, - usePerfLiveHistorySamples, + getPerfLiveHistorySamples, } from '../utils/perf/perfLiveHistory'; -import { acquirePerfLivePoll, usePerfLiveSnapshot } from '../utils/perf/perfLiveStore'; -import { hasAnyLiveMetricPollNeed, usePerfLiveOverlayPins } from '../utils/perf/perfOverlayPins'; +import { usePerfLiveSnapshot } from '../utils/perf/perfLiveStore'; +import { usePerfLiveOverlayPins } from '../utils/perf/perfOverlayPins'; import { perfOverlayCornerClass, usePerfOverlayAppearance, @@ -41,11 +39,12 @@ const QUEUE_STATS_MS = 750; function LiveOverlayPinnedMetric({ item, now, + history, }: { item: LiveOverlayItem; now: number; + history: ReturnType; }) { - const history = usePerfLiveHistorySamples(item.id); const sparklineKind = item.kind === 'memory' ? 'memory' : 'cpu'; return ( @@ -70,7 +69,6 @@ export default function FpsOverlay() { const [queueStats, setQueueStats] = useState(null); const [coverQueueLines, setCoverQueueLines] = useState([]); const last = useAnalysisPerfLast(); - const lastHistoryAt = useRef(0); const liveOverlayItems = useMemo( () => buildLiveOverlayItems(livePins, live), @@ -89,24 +87,13 @@ export default function FpsOverlay() { 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]); + const sparklineNow = useMemo( + () => (live.sampleAt > 0 ? live.sampleAt : Date.now()), + [live.sampleAt], + ); 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) { setTpm(0); @@ -219,7 +206,12 @@ export default function FpsOverlay() {
Live
{liveOverlayItems.map(item => ( - + ))}
)} diff --git a/src/components/perf/PerfOverlaySparkline.tsx b/src/components/perf/PerfOverlaySparkline.tsx index e74c77bf..90681ebf 100644 --- a/src/components/perf/PerfOverlaySparkline.tsx +++ b/src/components/perf/PerfOverlaySparkline.tsx @@ -62,7 +62,7 @@ function scale(samples: readonly PerfLiveSample[], kind: SparklineKind): { min: const minVal = Math.min(...values); const maxVal = Math.max(...values); if (kind === 'cpu') { - return { min: 0, max: Math.max(100, maxVal * 1.1) }; + return { min: 0, max: Math.max(5, maxVal * 1.25) }; } const pad = Math.max((maxVal - minVal) * 0.08, maxVal * 0.02, 1); return { min: Math.max(0, minVal - pad), max: maxVal + pad }; @@ -74,8 +74,10 @@ function stableScale( peakRef: { current: number }, ): { min: number; max: number } { const raw = scale(samples, kind); - if (kind === 'cpu') return raw; if (raw.max > peakRef.current) peakRef.current = raw.max; + if (kind === 'cpu') { + return { min: 0, max: Math.max(5, Math.max(peakRef.current, raw.max)) }; + } return { min: 0, max: Math.max(peakRef.current, raw.max) }; } diff --git a/src/components/settings/StorageTab.tsx b/src/components/settings/StorageTab.tsx index 0a68b0a6..605eef68 100644 --- a/src/components/settings/StorageTab.tsx +++ b/src/components/settings/StorageTab.tsx @@ -46,7 +46,7 @@ export function StorageTab() { }; refresh(); if (!auth.hotCacheEnabled) return; - const interval = window.setInterval(refresh, 2000); + const interval = window.setInterval(refresh, 15_000); return () => window.clearInterval(interval); }, [auth.hotCacheEnabled, auth.hotCacheDownloadDir]); diff --git a/src/components/sidebar/perfProbe/PerfLivePollControls.tsx b/src/components/sidebar/perfProbe/PerfLivePollControls.tsx index 39df88b1..fe7bf26e 100644 --- a/src/components/sidebar/perfProbe/PerfLivePollControls.tsx +++ b/src/components/sidebar/perfProbe/PerfLivePollControls.tsx @@ -1,3 +1,4 @@ +import { perfLiveCpuSnapshotSupported } from '../../../utils/perf/perfLiveCpuSnapshot'; import { PERF_LIVE_POLL_MS_MAX, PERF_LIVE_POLL_MS_MIN, @@ -11,6 +12,8 @@ import { export default function PerfLivePollControls() { const pollMs = usePerfLivePollIntervalMs(); const includeThreadGroups = usePerfLiveIncludeThreadGroups(); + if (!perfLiveCpuSnapshotSupported()) return null; + const pollSec = (pollMs / 1000).toFixed(1); return ( diff --git a/src/components/sidebar/perfProbe/SidebarPerfProbeMonitorTab.tsx b/src/components/sidebar/perfProbe/SidebarPerfProbeMonitorTab.tsx index aa3a799d..4c9ad606 100644 --- a/src/components/sidebar/perfProbe/SidebarPerfProbeMonitorTab.tsx +++ b/src/components/sidebar/perfProbe/SidebarPerfProbeMonitorTab.tsx @@ -1,5 +1,5 @@ import { useMemo, useRef } from 'react'; -import { usePerfLiveSnapshot } from '../../../utils/perf/perfLiveStore'; +import { isPerfLivePollWaitingForCpu, usePerfLiveSnapshot } from '../../../utils/perf/perfLiveStore'; import { usePerfLiveIncludeThreadGroups } from '../../../utils/perf/perfLivePollSettings'; import { togglePerfLiveOverlayPin, @@ -26,7 +26,7 @@ export default function SidebarPerfProbeMonitorTab() { const coverPinned = usePipelineOverlayPinned('pipeline:cover'); const cpu = live.cpu; const cpuSupported = cpu?.supported === true; - const collecting = live.collecting && cpu == null; + const collecting = isPerfLivePollWaitingForCpu(); const includeThreadGroups = usePerfLiveIncludeThreadGroups(); const peakMemoryKbRef = useRef(1); const peakThreadCpuRef = useRef(1); diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index d376778a..ffaaabd1 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -143,6 +143,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Library browse navigation: session restore on back for Artists, Search/Tracks, New Releases, Random Albums; unified SearchBrowsePage (PR #936)', 'Genres: local index genre browse with Subsonic fallback, aligned counts cache, scroll restore, hold-to-shuffle play (PR #937)', 'Live Search: scoped browse on Artists, Albums, New Releases, Tracks, and Composers — header badge, ghost restore, album title FTS, session stash (PR #938)', + 'Performance: idle Rust CPU — backfill coordinator park, probe overlay stability, throttled idle polls, lazy cover prefetch restore (PR #939)', ], }, { diff --git a/src/cover/useCoverArtPrefetch.ts b/src/cover/useCoverArtPrefetch.ts index 38f57458..6fef1ac5 100644 --- a/src/cover/useCoverArtPrefetch.ts +++ b/src/cover/useCoverArtPrefetch.ts @@ -5,18 +5,42 @@ import { useCoverStrategyStore } from '../store/coverStrategyStore'; import { useAuthStore } from '../store/authStore'; import { coverPrefetchDrainBatch } from './prefetchRegistry'; import { coverTrafficBackgroundPaused } from './coverTraffic'; -import { coverEnsureQueued } from './ensureQueue'; +import { coverEnsureQueued, coverEnsureQueueStats } from './ensureQueue'; +import { getDiskSrcForGrid } from './diskSrcLookup'; import { coverStorageKeyFromRef } from './storageKeys'; +import { warmCoverDiskSrcBatch, type CoverWarmItem } from './warmDiskPeek'; import { resolveCoverDisplayTier } from './tiers'; -import type { CoverArtTier } from './types'; +import type { CoverArtRef, CoverArtTier } from './types'; const STEADY_POLL_MS = 1500; +/** Full cover-root disk walk — idle only, not every prefetch tick. */ +const STATS_IDLE_POLL_MS = 30_000; const BATCH_LIMIT = 12; /** Match dense card thumbs (~160 CSS px) — prefetch 128 wasted a full re-ensure for 512. */ const DENSE_PREFETCH_TIER = resolveCoverDisplayTier(160, { surface: 'dense' }) as CoverArtTier; +function sleep(ms: number): Promise { + return new Promise(resolve => window.setTimeout(resolve, ms)); +} + +function batchWarmItems(refs: CoverArtRef[]): CoverWarmItem[] { + return refs.map(ref => ({ + ref, + tier: DENSE_PREFETCH_TIER, + storageKey: coverStorageKeyFromRef(ref, DENSE_PREFETCH_TIER), + })); +} + +/** Back off while viewport cells are waiting on high-priority ensures. */ +function prefetchShouldYieldToViewport(): boolean { + const { queuedHigh, inflight, maxInflight } = coverEnsureQueueStats(); + return queuedHigh > 0 || inflight >= maxInflight - 1; +} + /** * Background cover warm-up — low rate; Rust HTTP only (never competes with webview grid fetches). + * Registry drains: batched disk peek first (cached WebP → diskSrcCache), ensure only for misses. + * Stats (`cover_cache_stats` disk walk) run rarely when the registry is idle. */ export function useCoverArtPrefetch(enabled = true): void { const activeServerId = useAuthStore(s => s.activeServerId); @@ -25,31 +49,48 @@ export function useCoverArtPrefetch(enabled = true): void { useEffect(() => { if (!enabled || !activeServerId || !coverStrategyAllowsRoutePrefetch(strategy)) return; let cancelled = false; + let lastStatsAt = 0; + let autoDownloadEnabled = true; void (async () => { while (!cancelled) { if (coverTrafficBackgroundPaused()) { - await new Promise(r => setTimeout(r, STEADY_POLL_MS)); - continue; - } - - const stats = await coverCacheStats().catch(() => null); - if (stats && !stats.autoDownloadEnabled) { - await new Promise(r => setTimeout(r, STEADY_POLL_MS * 2)); + await sleep(STEADY_POLL_MS); continue; } const batch = coverPrefetchDrainBatch(BATCH_LIMIT); if (batch.length > 0) { - await Promise.all( - batch.map(ref => { - const key = coverStorageKeyFromRef(ref, DENSE_PREFETCH_TIER); - return coverEnsureQueued(key, ref, DENSE_PREFETCH_TIER, 'low'); - }), - ); + if (prefetchShouldYieldToViewport()) { + await sleep(STEADY_POLL_MS); + continue; + } + + await warmCoverDiskSrcBatch(batchWarmItems(batch)); + + if (autoDownloadEnabled) { + const misses = batch.filter(ref => !getDiskSrcForGrid(ref, DENSE_PREFETCH_TIER)); + if (misses.length > 0) { + await Promise.all( + misses.map(ref => { + const key = coverStorageKeyFromRef(ref, DENSE_PREFETCH_TIER); + return coverEnsureQueued(key, ref, DENSE_PREFETCH_TIER, 'low'); + }), + ); + } + } + await sleep(STEADY_POLL_MS); + continue; } - await new Promise(r => setTimeout(r, STEADY_POLL_MS)); + const now = Date.now(); + if (now - lastStatsAt >= STATS_IDLE_POLL_MS) { + const stats = await coverCacheStats().catch(() => null); + lastStatsAt = now; + autoDownloadEnabled = stats?.autoDownloadEnabled ?? true; + } + + await sleep(STEADY_POLL_MS); } })(); diff --git a/src/utils/perf/perfLiveCpuSnapshot.ts b/src/utils/perf/perfLiveCpuSnapshot.ts new file mode 100644 index 00000000..b5ae62b7 --- /dev/null +++ b/src/utils/perf/perfLiveCpuSnapshot.ts @@ -0,0 +1,6 @@ +import { IS_LINUX, IS_MACOS } from '../platform'; + +/** Matches Rust `performance_cpu_snapshot` (Linux `/proc`, macOS sysinfo). */ +export function perfLiveCpuSnapshotSupported(): boolean { + return IS_LINUX || IS_MACOS; +} diff --git a/src/utils/perf/perfLiveHistory.ts b/src/utils/perf/perfLiveHistory.ts index d45d2a4d..2a055b38 100644 --- a/src/utils/perf/perfLiveHistory.ts +++ b/src/utils/perf/perfLiveHistory.ts @@ -35,7 +35,7 @@ function appendSample(id: string, value: number, at: number): boolean { if (!Number.isFinite(value)) return false; const existing = series.get(id) ?? []; const last = existing[existing.length - 1]; - if (last && last.at === at && last.value === value) return false; + if (last && last.value === value) return false; const appended = last && last.at === at ? [...existing.slice(0, -1), { at, value }] @@ -50,23 +50,20 @@ export function recordPerfLiveHistory(id: string, value: number, at = Date.now() if (appendSample(id, value, at)) emit(); } -/** Record pinned live samples for one poll tick; returns the new last-recorded timestamp. */ +/** Record pinned live samples for one poll tick. */ export function syncPerfLiveHistoryFromPoll( pins: Iterable, live: PerfLiveSnapshot, - lastRecordedAt: number, -): number { - if (!live.cpu?.supported || live.updatedAt <= 0 || live.updatedAt === lastRecordedAt) { - return lastRecordedAt; - } + options?: { emit?: boolean }, +): void { + if (!live.cpu?.supported || live.sampleAt <= 0) return; let changed = false; for (const pin of pins) { if (!isLiveHistoryPin(pin)) continue; const value = liveOverlayItemValue(pin, live); - if (value != null && appendSample(pin, value, live.updatedAt)) changed = true; + if (value != null && appendSample(pin, value, live.sampleAt)) changed = true; } - if (changed) emit(); - return live.updatedAt; + if (changed && options?.emit !== false) emit(); } export function getPerfLiveHistoryClock(ids: Iterable): number { diff --git a/src/utils/perf/perfLiveStore.ts b/src/utils/perf/perfLiveStore.ts index 5b581145..8173bc81 100644 --- a/src/utils/perf/perfLiveStore.ts +++ b/src/utils/perf/perfLiveStore.ts @@ -1,7 +1,9 @@ import { useSyncExternalStore } from 'react'; import { invoke } from '@tauri-apps/api/core'; -import { clearPerfLiveHistory } from './perfLiveHistory'; +import { clearPerfLiveHistory, syncPerfLiveHistoryFromPoll } from './perfLiveHistory'; import { getAnalysisTracksPerMinute } from './analysisPerfStore'; +import { perfLiveCpuSnapshotSupported } from './perfLiveCpuSnapshot'; +import { getPerfLiveOverlayPins } from './perfOverlayPins'; import { buildPerfCpuSnapshotRequest, getPerfLivePollIntervalMs, @@ -46,8 +48,10 @@ export type PerfLiveSnapshot = { diagRates: PerfDiagRates | null; analysis: PerfAnalysisDiag | null; collecting: boolean; - /** Wall time of the last CPU poll; shared clock for overlay sparklines. */ + /** Wall time of the last displayed sample change (memory / diag / rates). */ updatedAt: number; + /** Wall time of the last CPU rate sample; stable sparkline clock between polls. */ + sampleAt: number; }; type ProcSnapshot = { @@ -66,6 +70,7 @@ const EMPTY: PerfLiveSnapshot = { analysis: null, collecting: false, updatedAt: 0, + sampleAt: 0, }; let snapshot: PerfLiveSnapshot = { ...EMPTY }; @@ -86,6 +91,56 @@ function setSnapshot(next: PerfLiveSnapshot): void { emit(); } +function memoryRowsEqual(a: readonly PerfProcessMemory[], b: readonly PerfProcessMemory[]): boolean { + if (a.length !== b.length) return false; + return a.every((row, index) => row.label === b[index].label && row.rss_kb === b[index].rss_kb); +} + +function threadCpuEqual(a: readonly PerfThreadCpu[], b: readonly PerfThreadCpu[]): boolean { + if (a.length !== b.length) return false; + return a.every((row, index) => ( + row.label === b[index].label + && row.pct === b[index].pct + && row.threadCount === b[index].threadCount + )); +} + +function diagRatesEqual(a: PerfDiagRates | null, b: PerfDiagRates | null): boolean { + if (a == null || b == null) return a === b; + return a.progress === b.progress && a.waveform === b.waveform && a.home === b.home; +} + +function analysisEqual(a: PerfAnalysisDiag | null, b: PerfAnalysisDiag | null): boolean { + if (a == null || b == null) return a === b; + return a.tracksPerMinute === b.tracksPerMinute + && a.lastTotalMs === b.lastTotalMs + && a.lastFetchMs === b.lastFetchMs + && a.lastSeedMs === b.lastSeedMs + && a.lastBpmMs === b.lastBpmMs; +} + +function cpuEqual(a: PerfLiveCpu | null, b: PerfLiveCpu | null): boolean { + if (a == null || b == null) return a === b; + return a.app === b.app + && a.webkit === b.webkit + && a.supported === b.supported + && memoryRowsEqual(a.memory, b.memory) + && threadCpuEqual(a.threadCpu, b.threadCpu); +} + +function publishLiveSnapshot(next: PerfLiveSnapshot): void { + const cpuChanged = !cpuEqual(snapshot.cpu, next.cpu); + const diagChanged = !diagRatesEqual(snapshot.diagRates, next.diagRates); + const analysisChanged = !analysisEqual(snapshot.analysis, next.analysis); + if (!cpuChanged && !diagChanged && !analysisChanged && next.updatedAt === snapshot.updatedAt) { + return; + } + if (next.sampleAt > snapshot.sampleAt && next.cpu?.supported) { + syncPerfLiveHistoryFromPoll(getPerfLiveOverlayPins(), next, { emit: false }); + } + setSnapshot(next); +} + function readUiCounters(): { progress: number; waveform: number; home: number } { const root = globalThis as unknown as { __psyPerfCounters?: Record }; const counters = root.__psyPerfCounters ?? {}; @@ -119,85 +174,139 @@ function nextDiagRates( }; } +const UNSUPPORTED_CPU: PerfLiveCpu = { + app: 0, + webkit: 0, + supported: false, + memory: [], + threadCpu: [], +}; + +function applyJsMetricsSnapshot(now: number): void { + const nextCounters = readUiCounters(); + const diagRates = nextDiagRates(nextCounters, now); + prevCounters = nextCounters; + prevCountersAt = now; + publishLiveSnapshot({ + cpu: snapshot.cpu ?? UNSUPPORTED_CPU, + diagRates, + analysis: buildAnalysisDiag(), + collecting: false, + updatedAt: now, + sampleAt: snapshot.sampleAt, + }); +} + async function pollOnce(): Promise { const generation = pollGeneration; - const now = Date.now(); + + if (!perfLiveCpuSnapshotSupported()) { + if (generation !== pollGeneration) return; + applyJsMetricsSnapshot(Date.now()); + return; + } + try { const snap = await invoke('performance_cpu_snapshot', buildPerfCpuSnapshotRequest()); if (generation !== pollGeneration) return; + const completedAt = Date.now(); const nextCounters = readUiCounters(); - const diagRates = nextDiagRates(nextCounters, now); + const diagRates = nextDiagRates(nextCounters, completedAt); prevCounters = nextCounters; - prevCountersAt = now; + prevCountersAt = completedAt; if (!snap.supported) { - setSnapshot({ - cpu: { app: 0, webkit: 0, supported: false, memory: [], threadCpu: [] }, + publishLiveSnapshot({ + cpu: UNSUPPORTED_CPU, diagRates, analysis: buildAnalysisDiag(), collecting: false, - updatedAt: now, + updatedAt: completedAt, + sampleAt: snapshot.sampleAt, }); return; } const memory = snap.memory; - let cpu: PerfLiveCpu = { - app: snapshot.cpu?.app ?? 0, - webkit: snapshot.cpu?.webkit ?? 0, - supported: true, - memory, - threadCpu: snap.thread_cpu_groups.map(g => ({ - label: g.label, - threadCount: g.thread_count, - pct: snapshot.cpu?.threadCpu.find(t => t.label === g.label)?.pct ?? 0, - })), - }; + const baselineProc = prevProc; + const prevCpu = snapshot.cpu; + let app = prevCpu?.app ?? 0; + let webkit = prevCpu?.webkit ?? 0; + let threadCpu: PerfThreadCpu[] = prevCpu?.threadCpu ?? snap.thread_cpu_groups.map(g => ({ + label: g.label, + threadCount: g.thread_count, + pct: 0, + })); + let rateSampleReady = false; - if (prevProc) { - const totalDelta = snap.total_jiffies - prevProc.total_jiffies; - const appDelta = snap.app_jiffies - prevProc.app_jiffies; - const webkitDelta = snap.webkit_jiffies - prevProc.webkit_jiffies; + if (baselineProc) { + const totalDelta = snap.total_jiffies - baselineProc.total_jiffies; + const appDelta = snap.app_jiffies - baselineProc.app_jiffies; + const webkitDelta = snap.webkit_jiffies - baselineProc.webkit_jiffies; if (totalDelta > 0) { + rateSampleReady = true; const cpuScale = Math.max(1, snap.logical_cpus || 1) * 100; const prevThreadByLabel = new Map( - prevProc.thread_cpu_groups.map(g => [g.label, g.jiffies]), + baselineProc.thread_cpu_groups.map(g => [g.label, g.jiffies]), ); - cpu = { - app: clampPct((appDelta / totalDelta) * cpuScale), - webkit: clampPct((webkitDelta / totalDelta) * cpuScale), - supported: true, - memory, - threadCpu: snap.thread_cpu_groups.map(g => { - const prevJiffies = prevThreadByLabel.get(g.label) ?? g.jiffies; - const delta = g.jiffies - prevJiffies; - return { - label: g.label, - threadCount: g.thread_count, - pct: clampPct((delta / totalDelta) * cpuScale), - }; - }), - }; + app = clampPct((appDelta / totalDelta) * cpuScale); + webkit = clampPct((webkitDelta / totalDelta) * cpuScale); + threadCpu = snap.thread_cpu_groups.map(g => { + const prevJiffies = prevThreadByLabel.get(g.label) ?? g.jiffies; + const delta = g.jiffies - prevJiffies; + return { + label: g.label, + threadCount: g.thread_count, + pct: clampPct((delta / totalDelta) * cpuScale), + }; + }); } } + const memoryChanged = !memoryRowsEqual(prevCpu?.memory ?? [], memory); + const diagChanged = !diagRatesEqual(snapshot.diagRates, diagRates); + const ratesChanged = rateSampleReady && ( + app !== (prevCpu?.app ?? 0) + || webkit !== (prevCpu?.webkit ?? 0) + || !threadCpuEqual(prevCpu?.threadCpu ?? [], threadCpu) + ); + prevProc = snap; - setSnapshot({ - cpu, + if (!rateSampleReady) { + if (baselineProc == null) return; + if (!memoryChanged && !diagChanged) return; + } + + const nextUpdatedAt = (ratesChanged || memoryChanged || diagChanged) + ? completedAt + : snapshot.updatedAt; + + const nextSampleAt = ratesChanged ? completedAt : snapshot.sampleAt; + + publishLiveSnapshot({ + cpu: { + app, + webkit, + supported: true, + memory, + threadCpu, + }, diagRates, analysis: buildAnalysisDiag(), collecting: false, - updatedAt: now, + updatedAt: nextUpdatedAt, + sampleAt: nextSampleAt, }); } catch { if (generation !== pollGeneration) return; - setSnapshot({ + publishLiveSnapshot({ ...snapshot, cpu: { app: 0, webkit: 0, supported: false, memory: [], threadCpu: [] }, collecting: false, updatedAt: Date.now(), + sampleAt: 0, }); } } @@ -209,7 +318,6 @@ function clampPct(value: number): number { function schedulePoll(): void { if (pollTimer != null) return; - setSnapshot({ ...snapshot, collecting: snapshot.cpu == null }); const intervalMs = getPerfLivePollIntervalMs(); const tick = () => { pollTimer = null; @@ -265,16 +373,23 @@ export function acquirePerfLivePoll(_reason: string): () => void { }; } +/** True while the first CPU baseline sample is still pending. */ +export function isPerfLivePollWaitingForCpu(): boolean { + return pollRefCount > 0 && snapshot.cpu == null && perfLiveCpuSnapshotSupported(); +} + export function patchPerfLiveAnalysis(partial: Partial): void { - setSnapshot({ + const nextAnalysis: PerfAnalysisDiag = { + tracksPerMinute: partial.tracksPerMinute ?? snapshot.analysis?.tracksPerMinute ?? 0, + lastTotalMs: partial.lastTotalMs ?? snapshot.analysis?.lastTotalMs ?? null, + lastFetchMs: partial.lastFetchMs ?? snapshot.analysis?.lastFetchMs ?? null, + lastSeedMs: partial.lastSeedMs ?? snapshot.analysis?.lastSeedMs ?? null, + lastBpmMs: partial.lastBpmMs ?? snapshot.analysis?.lastBpmMs ?? null, + }; + if (analysisEqual(snapshot.analysis, nextAnalysis)) return; + publishLiveSnapshot({ ...snapshot, - analysis: { - tracksPerMinute: partial.tracksPerMinute ?? snapshot.analysis?.tracksPerMinute ?? 0, - lastTotalMs: partial.lastTotalMs ?? snapshot.analysis?.lastTotalMs ?? null, - lastFetchMs: partial.lastFetchMs ?? snapshot.analysis?.lastFetchMs ?? null, - lastSeedMs: partial.lastSeedMs ?? snapshot.analysis?.lastSeedMs ?? null, - lastBpmMs: partial.lastBpmMs ?? snapshot.analysis?.lastBpmMs ?? null, - }, + analysis: nextAnalysis, }); } diff --git a/src/utils/perf/perfOverlayPins.ts b/src/utils/perf/perfOverlayPins.ts index e9884c64..61482d39 100644 --- a/src/utils/perf/perfOverlayPins.ts +++ b/src/utils/perf/perfOverlayPins.ts @@ -1,5 +1,6 @@ import { useSyncExternalStore } from 'react'; import { clearPerfLiveHistory } from './perfLiveHistory'; +import { perfLiveCpuSnapshotSupported } from './perfLiveCpuSnapshot'; import { getPerfOverlayMode } from './perfOverlayMode'; import { getPerfProbeFlags, setPerfProbeFlag, subscribePerfProbeFlags } from './perfFlags'; @@ -126,7 +127,16 @@ export function hasAnyPerfOverlayVisible(): boolean { ); } +function livePinsNeedJsPoll(pins: ReadonlySet): boolean { + for (const id of pins) { + if (id.startsWith('rate:') || id.startsWith('analysis:')) return true; + } + return false; +} + export function hasAnyLiveMetricPollNeed(): boolean { if (getPerfOverlayMode() !== 'pinned') return false; - return livePins.size > 0; + if (livePins.size === 0) return false; + if (perfLiveCpuSnapshotSupported()) return true; + return livePinsNeedJsPoll(livePins); }