mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
fix(perf): reduce idle Rust CPU and stabilize Performance Probe overlay (#939)
* 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.
This commit is contained in:
@@ -365,6 +365,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## Fixed
|
## 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
|
### CI — npmDepsHash on app-v* tags
|
||||||
|
|
||||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#927](https://github.com/Psychotoxical/psysonic/pull/927)**
|
**By [@cucadmuh](https://github.com/cucadmuh), PR [#927](https://github.com/Psychotoxical/psysonic/pull/927)**
|
||||||
|
|||||||
@@ -503,8 +503,16 @@ mod macos {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub(crate) fn performance_cpu_snapshot(include_thread_groups: Option<bool>) -> PerformanceCpuSnapshot {
|
pub(crate) async fn performance_cpu_snapshot(
|
||||||
|
include_thread_groups: Option<bool>,
|
||||||
|
) -> Result<PerformanceCpuSnapshot, String> {
|
||||||
let include_thread_groups = include_thread_groups.unwrap_or(false);
|
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")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
{
|
||||||
let total_jiffies = read_total_jiffies().unwrap_or(0);
|
let total_jiffies = read_total_jiffies().unwrap_or(0);
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
//!
|
//!
|
||||||
//! Replaces the webview `while` loop: top-up HTTP/CPU seed backlog without
|
//! Replaces the webview `while` loop: top-up HTTP/CPU seed backlog without
|
||||||
//! blocking the UI on `library_analysis_backfill_batch` IPC.
|
//! 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::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -25,7 +28,7 @@ use psysonic_library::repos::TrackRepository;
|
|||||||
use psysonic_library::LibraryRuntime;
|
use psysonic_library::LibraryRuntime;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tauri::{AppHandle, Listener, Manager};
|
use tauri::{AppHandle, Listener, Manager};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::{Mutex, Notify};
|
||||||
|
|
||||||
const TOP_UP_POLL_MS: u64 = 500;
|
const TOP_UP_POLL_MS: u64 = 500;
|
||||||
const STEADY_POLL_MS: u64 = 2000;
|
const STEADY_POLL_MS: u64 = 2000;
|
||||||
@@ -48,6 +51,8 @@ pub struct LibraryAnalysisBackfillSession {
|
|||||||
|
|
||||||
pub struct LibraryAnalysisBackfillWorker {
|
pub struct LibraryAnalysisBackfillWorker {
|
||||||
pub enabled: AtomicBool,
|
pub enabled: AtomicBool,
|
||||||
|
/// Wakes the coordinator task (configure, sync-idle, disable→enable).
|
||||||
|
wake: Notify,
|
||||||
session: Mutex<Option<LibraryAnalysisBackfillSession>>,
|
session: Mutex<Option<LibraryAnalysisBackfillSession>>,
|
||||||
cursor: Mutex<Option<String>>,
|
cursor: Mutex<Option<String>>,
|
||||||
scan_phase: Mutex<AnalysisBackfillScanPhase>,
|
scan_phase: Mutex<AnalysisBackfillScanPhase>,
|
||||||
@@ -59,6 +64,7 @@ impl LibraryAnalysisBackfillWorker {
|
|||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: AtomicBool::new(false),
|
enabled: AtomicBool::new(false),
|
||||||
|
wake: Notify::new(),
|
||||||
session: Mutex::new(None),
|
session: Mutex::new(None),
|
||||||
cursor: Mutex::new(None),
|
cursor: Mutex::new(None),
|
||||||
scan_phase: Mutex::new(AnalysisBackfillScanPhase::Candidates),
|
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<LibraryAnalysisBackfillSession>) {
|
pub async fn set_session(&self, enabled: bool, session: Option<LibraryAnalysisBackfillSession>) {
|
||||||
self.enabled.store(enabled, Ordering::Relaxed);
|
self.enabled.store(enabled, Ordering::Relaxed);
|
||||||
*self.session.lock().await = session;
|
*self.session.lock().await = session;
|
||||||
@@ -76,6 +86,7 @@ impl LibraryAnalysisBackfillWorker {
|
|||||||
*self.completed_total.lock().await = None;
|
*self.completed_total.lock().await = None;
|
||||||
*self.exhausted_streak.lock().await = 0;
|
*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
|
server_id == session.server_index_key || server_id == session.library_server_id
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CoordinatorTick {
|
enum CoordinatorStep {
|
||||||
sleep_ms: u64,
|
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<LibraryAnalysisBackfillWorker>) {
|
async fn run_coordinator_forever(app: AppHandle, worker: Arc<LibraryAnalysisBackfillWorker>) {
|
||||||
loop {
|
loop {
|
||||||
let tick = coordinator_tick(&app, worker.as_ref()).await;
|
while !worker.enabled.load(Ordering::Relaxed) {
|
||||||
tokio::time::sleep(Duration::from_millis(tick.sleep_ms)).await;
|
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(
|
async fn coordinator_tick(
|
||||||
app: &AppHandle,
|
app: &AppHandle,
|
||||||
worker: &LibraryAnalysisBackfillWorker,
|
worker: &LibraryAnalysisBackfillWorker,
|
||||||
) -> CoordinatorTick {
|
) -> CoordinatorStep {
|
||||||
if !worker.enabled.load(Ordering::Relaxed) {
|
if !worker.enabled.load(Ordering::Relaxed) {
|
||||||
return CoordinatorTick { sleep_ms: STEADY_POLL_MS };
|
return CoordinatorStep::Park;
|
||||||
}
|
}
|
||||||
|
|
||||||
let session = worker.session.lock().await.clone();
|
let session = worker.session.lock().await.clone();
|
||||||
let Some(session) = session else {
|
let Some(session) = session else {
|
||||||
return CoordinatorTick { sleep_ms: STEADY_POLL_MS };
|
return CoordinatorStep::Park;
|
||||||
};
|
};
|
||||||
|
|
||||||
if !session_still_focused(worker, &session).await {
|
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);
|
analysis_set_pipeline_parallelism(session.workers as usize);
|
||||||
|
|
||||||
let runtime = match app.try_state::<LibraryRuntime>() {
|
let runtime = match app.try_state::<LibraryRuntime>() {
|
||||||
Some(r) => r,
|
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 {
|
if let Some(done_total) = *worker.completed_total.lock().await {
|
||||||
@@ -155,9 +191,7 @@ async fn coordinator_tick(
|
|||||||
.and_then(|r| r.ok())
|
.and_then(|r| r.ok())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if still_same {
|
if still_same {
|
||||||
return CoordinatorTick {
|
return CoordinatorStep::Sleep(Duration::from_millis(COMPLETED_RECHECK_MS));
|
||||||
sleep_ms: COMPLETED_RECHECK_MS,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
*worker.completed_total.lock().await = None;
|
*worker.completed_total.lock().await = None;
|
||||||
*worker.cursor.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) {
|
while sync_blocks_backfill(&runtime.store, &session.library_server_id) {
|
||||||
if !worker.enabled.load(Ordering::Relaxed) {
|
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();
|
let store = runtime.store.clone();
|
||||||
@@ -180,7 +214,7 @@ async fn coordinator_tick(
|
|||||||
.and_then(|r| r.ok())
|
.and_then(|r| r.ok())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if !ready {
|
if !ready {
|
||||||
return CoordinatorTick { sleep_ms: READY_POLL_MS };
|
return CoordinatorStep::Sleep(Duration::from_millis(READY_POLL_MS));
|
||||||
}
|
}
|
||||||
|
|
||||||
let stats = analysis_pipeline_queue_stats();
|
let stats = analysis_pipeline_queue_stats();
|
||||||
@@ -192,12 +226,12 @@ async fn coordinator_tick(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if !library_backfill_needs_top_up(counts, session.workers) {
|
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);
|
let fetch_limit = library_backfill_top_up_limit(counts, session.workers);
|
||||||
if fetch_limit == 0 {
|
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();
|
let cursor = worker.cursor.lock().await.clone();
|
||||||
@@ -223,7 +257,7 @@ async fn coordinator_tick(
|
|||||||
.and_then(|r| r.ok());
|
.and_then(|r| r.ok());
|
||||||
|
|
||||||
let Some((batch, next_phase)) = batch else {
|
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();
|
*worker.cursor.lock().await = batch.next_cursor.clone();
|
||||||
@@ -279,9 +313,7 @@ async fn coordinator_tick(
|
|||||||
}
|
}
|
||||||
*worker.cursor.lock().await = None;
|
*worker.cursor.lock().await = None;
|
||||||
*worker.scan_phase.lock().await = AnalysisBackfillScanPhase::Candidates;
|
*worker.scan_phase.lock().await = AnalysisBackfillScanPhase::Candidates;
|
||||||
return CoordinatorTick {
|
return CoordinatorStep::Sleep(Duration::from_millis(COMPLETED_RECHECK_MS));
|
||||||
sleep_ms: COMPLETED_RECHECK_MS,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
*worker.exhausted_streak.lock().await = 0;
|
*worker.exhausted_streak.lock().await = 0;
|
||||||
@@ -289,18 +321,14 @@ async fn coordinator_tick(
|
|||||||
if pending > 0 {
|
if pending > 0 {
|
||||||
*worker.cursor.lock().await = None;
|
*worker.cursor.lock().await = None;
|
||||||
*worker.scan_phase.lock().await = AnalysisBackfillScanPhase::Candidates;
|
*worker.scan_phase.lock().await = AnalysisBackfillScanPhase::Candidates;
|
||||||
return CoordinatorTick {
|
return CoordinatorStep::Sleep(Duration::from_millis(EXHAUSTED_PENDING_RESCAN_MS));
|
||||||
sleep_ms: EXHAUSTED_PENDING_RESCAN_MS,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
*worker.cursor.lock().await = None;
|
*worker.cursor.lock().await = None;
|
||||||
*worker.scan_phase.lock().await = AnalysisBackfillScanPhase::Candidates;
|
*worker.scan_phase.lock().await = AnalysisBackfillScanPhase::Candidates;
|
||||||
return CoordinatorTick {
|
return CoordinatorStep::Sleep(Duration::from_millis(EXHAUSTED_PAUSE_MS));
|
||||||
sleep_ms: EXHAUSTED_PAUSE_MS,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CoordinatorTick { sleep_ms: TOP_UP_POLL_MS }
|
CoordinatorStep::Sleep(Duration::from_millis(TOP_UP_POLL_MS))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -333,6 +361,7 @@ fn on_sync_idle(app: &AppHandle, payload: SyncIdlePayload) {
|
|||||||
*worker.cursor.lock().await = None;
|
*worker.cursor.lock().await = None;
|
||||||
*worker.scan_phase.lock().await = AnalysisBackfillScanPhase::Candidates;
|
*worker.scan_phase.lock().await = AnalysisBackfillScanPhase::Candidates;
|
||||||
*worker.exhausted_streak.lock().await = 0;
|
*worker.exhausted_streak.lock().await = 0;
|
||||||
|
worker.ping_coordinator();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { createPortal } from 'react-dom';
|
||||||
import { analysisGetPipelineQueueStats, type AnalysisPipelineQueueStatsDto } from '../api/analysis';
|
import { analysisGetPipelineQueueStats, type AnalysisPipelineQueueStatsDto } from '../api/analysis';
|
||||||
import { coverGetPipelineQueueStats, type CoverPipelineQueueStatsDto } from '../api/coverCache';
|
import { coverGetPipelineQueueStats, type CoverPipelineQueueStatsDto } from '../api/coverCache';
|
||||||
@@ -18,12 +18,10 @@ import {
|
|||||||
type LiveOverlayItem,
|
type LiveOverlayItem,
|
||||||
} from '../utils/perf/formatLiveOverlayItems';
|
} from '../utils/perf/formatLiveOverlayItems';
|
||||||
import {
|
import {
|
||||||
getPerfLiveHistoryClock,
|
getPerfLiveHistorySamples,
|
||||||
syncPerfLiveHistoryFromPoll,
|
|
||||||
usePerfLiveHistorySamples,
|
|
||||||
} from '../utils/perf/perfLiveHistory';
|
} from '../utils/perf/perfLiveHistory';
|
||||||
import { acquirePerfLivePoll, usePerfLiveSnapshot } from '../utils/perf/perfLiveStore';
|
import { usePerfLiveSnapshot } from '../utils/perf/perfLiveStore';
|
||||||
import { hasAnyLiveMetricPollNeed, usePerfLiveOverlayPins } from '../utils/perf/perfOverlayPins';
|
import { usePerfLiveOverlayPins } from '../utils/perf/perfOverlayPins';
|
||||||
import {
|
import {
|
||||||
perfOverlayCornerClass,
|
perfOverlayCornerClass,
|
||||||
usePerfOverlayAppearance,
|
usePerfOverlayAppearance,
|
||||||
@@ -41,11 +39,12 @@ const QUEUE_STATS_MS = 750;
|
|||||||
function LiveOverlayPinnedMetric({
|
function LiveOverlayPinnedMetric({
|
||||||
item,
|
item,
|
||||||
now,
|
now,
|
||||||
|
history,
|
||||||
}: {
|
}: {
|
||||||
item: LiveOverlayItem;
|
item: LiveOverlayItem;
|
||||||
now: number;
|
now: number;
|
||||||
|
history: ReturnType<typeof getPerfLiveHistorySamples>;
|
||||||
}) {
|
}) {
|
||||||
const history = usePerfLiveHistorySamples(item.id);
|
|
||||||
const sparklineKind = item.kind === 'memory' ? 'memory' : 'cpu';
|
const sparklineKind = item.kind === 'memory' ? 'memory' : 'cpu';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -70,7 +69,6 @@ export default function FpsOverlay() {
|
|||||||
const [queueStats, setQueueStats] = useState<AnalysisPipelineQueueStatsDto | null>(null);
|
const [queueStats, setQueueStats] = useState<AnalysisPipelineQueueStatsDto | null>(null);
|
||||||
const [coverQueueLines, setCoverQueueLines] = useState<string[]>([]);
|
const [coverQueueLines, setCoverQueueLines] = useState<string[]>([]);
|
||||||
const last = useAnalysisPerfLast();
|
const last = useAnalysisPerfLast();
|
||||||
const lastHistoryAt = useRef(0);
|
|
||||||
|
|
||||||
const liveOverlayItems = useMemo(
|
const liveOverlayItems = useMemo(
|
||||||
() => buildLiveOverlayItems(livePins, live),
|
() => buildLiveOverlayItems(livePins, live),
|
||||||
@@ -89,24 +87,13 @@ export default function FpsOverlay() {
|
|||||||
showLive,
|
showLive,
|
||||||
} = visibility;
|
} = visibility;
|
||||||
|
|
||||||
lastHistoryAt.current = overlayMode === 'pinned'
|
const sparklineNow = useMemo(
|
||||||
? syncPerfLiveHistoryFromPoll(livePins, live, lastHistoryAt.current)
|
() => (live.sampleAt > 0 ? live.sampleAt : Date.now()),
|
||||||
: lastHistoryAt.current;
|
[live.sampleAt],
|
||||||
|
);
|
||||||
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'));
|
useAnalysisPerfListener(showAnalysisPerfOverlay || livePins.has('analysis:tpm') || livePins.has('analysis:last'));
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (overlayMode !== 'pinned' || !hasAnyLiveMetricPollNeed()) return;
|
|
||||||
return acquirePerfLivePoll('overlay-pins');
|
|
||||||
}, [overlayMode, livePins.size]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showAnalysisPerfOverlay) {
|
if (!showAnalysisPerfOverlay) {
|
||||||
setTpm(0);
|
setTpm(0);
|
||||||
@@ -219,7 +206,12 @@ export default function FpsOverlay() {
|
|||||||
<div className="fps-overlay__block">
|
<div className="fps-overlay__block">
|
||||||
<div className="fps-overlay__block-title">Live</div>
|
<div className="fps-overlay__block-title">Live</div>
|
||||||
{liveOverlayItems.map(item => (
|
{liveOverlayItems.map(item => (
|
||||||
<LiveOverlayPinnedMetric key={item.id} item={item} now={sparklineNow} />
|
<LiveOverlayPinnedMetric
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
now={sparklineNow}
|
||||||
|
history={item.sparkline ? getPerfLiveHistorySamples(item.id) : []}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ function scale(samples: readonly PerfLiveSample[], kind: SparklineKind): { min:
|
|||||||
const minVal = Math.min(...values);
|
const minVal = Math.min(...values);
|
||||||
const maxVal = Math.max(...values);
|
const maxVal = Math.max(...values);
|
||||||
if (kind === 'cpu') {
|
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);
|
const pad = Math.max((maxVal - minVal) * 0.08, maxVal * 0.02, 1);
|
||||||
return { min: Math.max(0, minVal - pad), max: maxVal + pad };
|
return { min: Math.max(0, minVal - pad), max: maxVal + pad };
|
||||||
@@ -74,8 +74,10 @@ function stableScale(
|
|||||||
peakRef: { current: number },
|
peakRef: { current: number },
|
||||||
): { min: number; max: number } {
|
): { min: number; max: number } {
|
||||||
const raw = scale(samples, kind);
|
const raw = scale(samples, kind);
|
||||||
if (kind === 'cpu') return raw;
|
|
||||||
if (raw.max > peakRef.current) peakRef.current = raw.max;
|
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) };
|
return { min: 0, max: Math.max(peakRef.current, raw.max) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function StorageTab() {
|
|||||||
};
|
};
|
||||||
refresh();
|
refresh();
|
||||||
if (!auth.hotCacheEnabled) return;
|
if (!auth.hotCacheEnabled) return;
|
||||||
const interval = window.setInterval(refresh, 2000);
|
const interval = window.setInterval(refresh, 15_000);
|
||||||
return () => window.clearInterval(interval);
|
return () => window.clearInterval(interval);
|
||||||
}, [auth.hotCacheEnabled, auth.hotCacheDownloadDir]);
|
}, [auth.hotCacheEnabled, auth.hotCacheDownloadDir]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { perfLiveCpuSnapshotSupported } from '../../../utils/perf/perfLiveCpuSnapshot';
|
||||||
import {
|
import {
|
||||||
PERF_LIVE_POLL_MS_MAX,
|
PERF_LIVE_POLL_MS_MAX,
|
||||||
PERF_LIVE_POLL_MS_MIN,
|
PERF_LIVE_POLL_MS_MIN,
|
||||||
@@ -11,6 +12,8 @@ import {
|
|||||||
export default function PerfLivePollControls() {
|
export default function PerfLivePollControls() {
|
||||||
const pollMs = usePerfLivePollIntervalMs();
|
const pollMs = usePerfLivePollIntervalMs();
|
||||||
const includeThreadGroups = usePerfLiveIncludeThreadGroups();
|
const includeThreadGroups = usePerfLiveIncludeThreadGroups();
|
||||||
|
if (!perfLiveCpuSnapshotSupported()) return null;
|
||||||
|
|
||||||
const pollSec = (pollMs / 1000).toFixed(1);
|
const pollSec = (pollMs / 1000).toFixed(1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useRef } from 'react';
|
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 { usePerfLiveIncludeThreadGroups } from '../../../utils/perf/perfLivePollSettings';
|
||||||
import {
|
import {
|
||||||
togglePerfLiveOverlayPin,
|
togglePerfLiveOverlayPin,
|
||||||
@@ -26,7 +26,7 @@ export default function SidebarPerfProbeMonitorTab() {
|
|||||||
const coverPinned = usePipelineOverlayPinned('pipeline:cover');
|
const coverPinned = usePipelineOverlayPinned('pipeline:cover');
|
||||||
const cpu = live.cpu;
|
const cpu = live.cpu;
|
||||||
const cpuSupported = cpu?.supported === true;
|
const cpuSupported = cpu?.supported === true;
|
||||||
const collecting = live.collecting && cpu == null;
|
const collecting = isPerfLivePollWaitingForCpu();
|
||||||
const includeThreadGroups = usePerfLiveIncludeThreadGroups();
|
const includeThreadGroups = usePerfLiveIncludeThreadGroups();
|
||||||
const peakMemoryKbRef = useRef(1);
|
const peakMemoryKbRef = useRef(1);
|
||||||
const peakThreadCpuRef = useRef(1);
|
const peakThreadCpuRef = useRef(1);
|
||||||
|
|||||||
@@ -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)',
|
'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)',
|
'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)',
|
'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)',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,18 +5,42 @@ import { useCoverStrategyStore } from '../store/coverStrategyStore';
|
|||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { coverPrefetchDrainBatch } from './prefetchRegistry';
|
import { coverPrefetchDrainBatch } from './prefetchRegistry';
|
||||||
import { coverTrafficBackgroundPaused } from './coverTraffic';
|
import { coverTrafficBackgroundPaused } from './coverTraffic';
|
||||||
import { coverEnsureQueued } from './ensureQueue';
|
import { coverEnsureQueued, coverEnsureQueueStats } from './ensureQueue';
|
||||||
|
import { getDiskSrcForGrid } from './diskSrcLookup';
|
||||||
import { coverStorageKeyFromRef } from './storageKeys';
|
import { coverStorageKeyFromRef } from './storageKeys';
|
||||||
|
import { warmCoverDiskSrcBatch, type CoverWarmItem } from './warmDiskPeek';
|
||||||
import { resolveCoverDisplayTier } from './tiers';
|
import { resolveCoverDisplayTier } from './tiers';
|
||||||
import type { CoverArtTier } from './types';
|
import type { CoverArtRef, CoverArtTier } from './types';
|
||||||
|
|
||||||
const STEADY_POLL_MS = 1500;
|
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;
|
const BATCH_LIMIT = 12;
|
||||||
/** Match dense card thumbs (~160 CSS px) — prefetch 128 wasted a full re-ensure for 512. */
|
/** 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;
|
const DENSE_PREFETCH_TIER = resolveCoverDisplayTier(160, { surface: 'dense' }) as CoverArtTier;
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
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).
|
* 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 {
|
export function useCoverArtPrefetch(enabled = true): void {
|
||||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||||
@@ -25,31 +49,48 @@ export function useCoverArtPrefetch(enabled = true): void {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled || !activeServerId || !coverStrategyAllowsRoutePrefetch(strategy)) return;
|
if (!enabled || !activeServerId || !coverStrategyAllowsRoutePrefetch(strategy)) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
let lastStatsAt = 0;
|
||||||
|
let autoDownloadEnabled = true;
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
while (!cancelled) {
|
while (!cancelled) {
|
||||||
if (coverTrafficBackgroundPaused()) {
|
if (coverTrafficBackgroundPaused()) {
|
||||||
await new Promise(r => setTimeout(r, STEADY_POLL_MS));
|
await sleep(STEADY_POLL_MS);
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = await coverCacheStats().catch(() => null);
|
|
||||||
if (stats && !stats.autoDownloadEnabled) {
|
|
||||||
await new Promise(r => setTimeout(r, STEADY_POLL_MS * 2));
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const batch = coverPrefetchDrainBatch(BATCH_LIMIT);
|
const batch = coverPrefetchDrainBatch(BATCH_LIMIT);
|
||||||
if (batch.length > 0) {
|
if (batch.length > 0) {
|
||||||
await Promise.all(
|
if (prefetchShouldYieldToViewport()) {
|
||||||
batch.map(ref => {
|
await sleep(STEADY_POLL_MS);
|
||||||
const key = coverStorageKeyFromRef(ref, DENSE_PREFETCH_TIER);
|
continue;
|
||||||
return coverEnsureQueued(key, ref, DENSE_PREFETCH_TIER, 'low');
|
}
|
||||||
}),
|
|
||||||
);
|
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);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ function appendSample(id: string, value: number, at: number): boolean {
|
|||||||
if (!Number.isFinite(value)) return false;
|
if (!Number.isFinite(value)) return false;
|
||||||
const existing = series.get(id) ?? [];
|
const existing = series.get(id) ?? [];
|
||||||
const last = existing[existing.length - 1];
|
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 =
|
const appended =
|
||||||
last && last.at === at
|
last && last.at === at
|
||||||
? [...existing.slice(0, -1), { at, value }]
|
? [...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();
|
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(
|
export function syncPerfLiveHistoryFromPoll(
|
||||||
pins: Iterable<string>,
|
pins: Iterable<string>,
|
||||||
live: PerfLiveSnapshot,
|
live: PerfLiveSnapshot,
|
||||||
lastRecordedAt: number,
|
options?: { emit?: boolean },
|
||||||
): number {
|
): void {
|
||||||
if (!live.cpu?.supported || live.updatedAt <= 0 || live.updatedAt === lastRecordedAt) {
|
if (!live.cpu?.supported || live.sampleAt <= 0) return;
|
||||||
return lastRecordedAt;
|
|
||||||
}
|
|
||||||
let changed = false;
|
let changed = false;
|
||||||
for (const pin of pins) {
|
for (const pin of pins) {
|
||||||
if (!isLiveHistoryPin(pin)) continue;
|
if (!isLiveHistoryPin(pin)) continue;
|
||||||
const value = liveOverlayItemValue(pin, live);
|
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();
|
if (changed && options?.emit !== false) emit();
|
||||||
return live.updatedAt;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPerfLiveHistoryClock(ids: Iterable<string>): number {
|
export function getPerfLiveHistoryClock(ids: Iterable<string>): number {
|
||||||
|
|||||||
+167
-52
@@ -1,7 +1,9 @@
|
|||||||
import { useSyncExternalStore } from 'react';
|
import { useSyncExternalStore } from 'react';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { clearPerfLiveHistory } from './perfLiveHistory';
|
import { clearPerfLiveHistory, syncPerfLiveHistoryFromPoll } from './perfLiveHistory';
|
||||||
import { getAnalysisTracksPerMinute } from './analysisPerfStore';
|
import { getAnalysisTracksPerMinute } from './analysisPerfStore';
|
||||||
|
import { perfLiveCpuSnapshotSupported } from './perfLiveCpuSnapshot';
|
||||||
|
import { getPerfLiveOverlayPins } from './perfOverlayPins';
|
||||||
import {
|
import {
|
||||||
buildPerfCpuSnapshotRequest,
|
buildPerfCpuSnapshotRequest,
|
||||||
getPerfLivePollIntervalMs,
|
getPerfLivePollIntervalMs,
|
||||||
@@ -46,8 +48,10 @@ export type PerfLiveSnapshot = {
|
|||||||
diagRates: PerfDiagRates | null;
|
diagRates: PerfDiagRates | null;
|
||||||
analysis: PerfAnalysisDiag | null;
|
analysis: PerfAnalysisDiag | null;
|
||||||
collecting: boolean;
|
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;
|
updatedAt: number;
|
||||||
|
/** Wall time of the last CPU rate sample; stable sparkline clock between polls. */
|
||||||
|
sampleAt: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ProcSnapshot = {
|
type ProcSnapshot = {
|
||||||
@@ -66,6 +70,7 @@ const EMPTY: PerfLiveSnapshot = {
|
|||||||
analysis: null,
|
analysis: null,
|
||||||
collecting: false,
|
collecting: false,
|
||||||
updatedAt: 0,
|
updatedAt: 0,
|
||||||
|
sampleAt: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
let snapshot: PerfLiveSnapshot = { ...EMPTY };
|
let snapshot: PerfLiveSnapshot = { ...EMPTY };
|
||||||
@@ -86,6 +91,56 @@ function setSnapshot(next: PerfLiveSnapshot): void {
|
|||||||
emit();
|
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 } {
|
function readUiCounters(): { progress: number; waveform: number; home: number } {
|
||||||
const root = globalThis as unknown as { __psyPerfCounters?: Record<string, number> };
|
const root = globalThis as unknown as { __psyPerfCounters?: Record<string, number> };
|
||||||
const counters = root.__psyPerfCounters ?? {};
|
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<void> {
|
async function pollOnce(): Promise<void> {
|
||||||
const generation = pollGeneration;
|
const generation = pollGeneration;
|
||||||
const now = Date.now();
|
|
||||||
|
if (!perfLiveCpuSnapshotSupported()) {
|
||||||
|
if (generation !== pollGeneration) return;
|
||||||
|
applyJsMetricsSnapshot(Date.now());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const snap = await invoke<ProcSnapshot>('performance_cpu_snapshot', buildPerfCpuSnapshotRequest());
|
const snap = await invoke<ProcSnapshot>('performance_cpu_snapshot', buildPerfCpuSnapshotRequest());
|
||||||
if (generation !== pollGeneration) return;
|
if (generation !== pollGeneration) return;
|
||||||
|
|
||||||
|
const completedAt = Date.now();
|
||||||
const nextCounters = readUiCounters();
|
const nextCounters = readUiCounters();
|
||||||
const diagRates = nextDiagRates(nextCounters, now);
|
const diagRates = nextDiagRates(nextCounters, completedAt);
|
||||||
prevCounters = nextCounters;
|
prevCounters = nextCounters;
|
||||||
prevCountersAt = now;
|
prevCountersAt = completedAt;
|
||||||
|
|
||||||
if (!snap.supported) {
|
if (!snap.supported) {
|
||||||
setSnapshot({
|
publishLiveSnapshot({
|
||||||
cpu: { app: 0, webkit: 0, supported: false, memory: [], threadCpu: [] },
|
cpu: UNSUPPORTED_CPU,
|
||||||
diagRates,
|
diagRates,
|
||||||
analysis: buildAnalysisDiag(),
|
analysis: buildAnalysisDiag(),
|
||||||
collecting: false,
|
collecting: false,
|
||||||
updatedAt: now,
|
updatedAt: completedAt,
|
||||||
|
sampleAt: snapshot.sampleAt,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const memory = snap.memory;
|
const memory = snap.memory;
|
||||||
let cpu: PerfLiveCpu = {
|
const baselineProc = prevProc;
|
||||||
app: snapshot.cpu?.app ?? 0,
|
const prevCpu = snapshot.cpu;
|
||||||
webkit: snapshot.cpu?.webkit ?? 0,
|
let app = prevCpu?.app ?? 0;
|
||||||
supported: true,
|
let webkit = prevCpu?.webkit ?? 0;
|
||||||
memory,
|
let threadCpu: PerfThreadCpu[] = prevCpu?.threadCpu ?? snap.thread_cpu_groups.map(g => ({
|
||||||
threadCpu: snap.thread_cpu_groups.map(g => ({
|
label: g.label,
|
||||||
label: g.label,
|
threadCount: g.thread_count,
|
||||||
threadCount: g.thread_count,
|
pct: 0,
|
||||||
pct: snapshot.cpu?.threadCpu.find(t => t.label === g.label)?.pct ?? 0,
|
}));
|
||||||
})),
|
let rateSampleReady = false;
|
||||||
};
|
|
||||||
|
|
||||||
if (prevProc) {
|
if (baselineProc) {
|
||||||
const totalDelta = snap.total_jiffies - prevProc.total_jiffies;
|
const totalDelta = snap.total_jiffies - baselineProc.total_jiffies;
|
||||||
const appDelta = snap.app_jiffies - prevProc.app_jiffies;
|
const appDelta = snap.app_jiffies - baselineProc.app_jiffies;
|
||||||
const webkitDelta = snap.webkit_jiffies - prevProc.webkit_jiffies;
|
const webkitDelta = snap.webkit_jiffies - baselineProc.webkit_jiffies;
|
||||||
if (totalDelta > 0) {
|
if (totalDelta > 0) {
|
||||||
|
rateSampleReady = true;
|
||||||
const cpuScale = Math.max(1, snap.logical_cpus || 1) * 100;
|
const cpuScale = Math.max(1, snap.logical_cpus || 1) * 100;
|
||||||
const prevThreadByLabel = new Map(
|
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);
|
||||||
app: clampPct((appDelta / totalDelta) * cpuScale),
|
webkit = clampPct((webkitDelta / totalDelta) * cpuScale);
|
||||||
webkit: clampPct((webkitDelta / totalDelta) * cpuScale),
|
threadCpu = snap.thread_cpu_groups.map(g => {
|
||||||
supported: true,
|
const prevJiffies = prevThreadByLabel.get(g.label) ?? g.jiffies;
|
||||||
memory,
|
const delta = g.jiffies - prevJiffies;
|
||||||
threadCpu: snap.thread_cpu_groups.map(g => {
|
return {
|
||||||
const prevJiffies = prevThreadByLabel.get(g.label) ?? g.jiffies;
|
label: g.label,
|
||||||
const delta = g.jiffies - prevJiffies;
|
threadCount: g.thread_count,
|
||||||
return {
|
pct: clampPct((delta / totalDelta) * cpuScale),
|
||||||
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;
|
prevProc = snap;
|
||||||
|
|
||||||
setSnapshot({
|
if (!rateSampleReady) {
|
||||||
cpu,
|
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,
|
diagRates,
|
||||||
analysis: buildAnalysisDiag(),
|
analysis: buildAnalysisDiag(),
|
||||||
collecting: false,
|
collecting: false,
|
||||||
updatedAt: now,
|
updatedAt: nextUpdatedAt,
|
||||||
|
sampleAt: nextSampleAt,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
if (generation !== pollGeneration) return;
|
if (generation !== pollGeneration) return;
|
||||||
setSnapshot({
|
publishLiveSnapshot({
|
||||||
...snapshot,
|
...snapshot,
|
||||||
cpu: { app: 0, webkit: 0, supported: false, memory: [], threadCpu: [] },
|
cpu: { app: 0, webkit: 0, supported: false, memory: [], threadCpu: [] },
|
||||||
collecting: false,
|
collecting: false,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
|
sampleAt: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,7 +318,6 @@ function clampPct(value: number): number {
|
|||||||
|
|
||||||
function schedulePoll(): void {
|
function schedulePoll(): void {
|
||||||
if (pollTimer != null) return;
|
if (pollTimer != null) return;
|
||||||
setSnapshot({ ...snapshot, collecting: snapshot.cpu == null });
|
|
||||||
const intervalMs = getPerfLivePollIntervalMs();
|
const intervalMs = getPerfLivePollIntervalMs();
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
pollTimer = null;
|
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<PerfAnalysisDiag>): void {
|
export function patchPerfLiveAnalysis(partial: Partial<PerfAnalysisDiag>): 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,
|
...snapshot,
|
||||||
analysis: {
|
analysis: nextAnalysis,
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useSyncExternalStore } from 'react';
|
import { useSyncExternalStore } from 'react';
|
||||||
import { clearPerfLiveHistory } from './perfLiveHistory';
|
import { clearPerfLiveHistory } from './perfLiveHistory';
|
||||||
|
import { perfLiveCpuSnapshotSupported } from './perfLiveCpuSnapshot';
|
||||||
import { getPerfOverlayMode } from './perfOverlayMode';
|
import { getPerfOverlayMode } from './perfOverlayMode';
|
||||||
import { getPerfProbeFlags, setPerfProbeFlag, subscribePerfProbeFlags } from './perfFlags';
|
import { getPerfProbeFlags, setPerfProbeFlag, subscribePerfProbeFlags } from './perfFlags';
|
||||||
|
|
||||||
@@ -126,7 +127,16 @@ export function hasAnyPerfOverlayVisible(): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function livePinsNeedJsPoll(pins: ReadonlySet<string>): boolean {
|
||||||
|
for (const id of pins) {
|
||||||
|
if (id.startsWith('rate:') || id.startsWith('analysis:')) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export function hasAnyLiveMetricPollNeed(): boolean {
|
export function hasAnyLiveMetricPollNeed(): boolean {
|
||||||
if (getPerfOverlayMode() !== 'pinned') return false;
|
if (getPerfOverlayMode() !== 'pinned') return false;
|
||||||
return livePins.size > 0;
|
if (livePins.size === 0) return false;
|
||||||
|
if (perfLiveCpuSnapshotSupported()) return true;
|
||||||
|
return livePinsNeedJsPoll(livePins);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user