401 lines
15 KiB
Rust
401 lines
15 KiB
Rust
//! Dev diagnostics for the debug overlay (Ctrl+Shift+D): system + process
|
||
//! CPU, memory, GPU, network rates, and the loudness-normalization state of
|
||
//! the active track. Polled by the frontend only while the overlay is
|
||
//! visible — nothing here runs otherwise.
|
||
//!
|
||
//! Process figures aggregate the WHOLE process tree: the WebView2 children
|
||
//! do the actual rendering, so app.exe alone would tell a comforting lie.
|
||
|
||
use std::collections::HashMap;
|
||
use std::sync::{Arc, Mutex};
|
||
use std::time::Instant;
|
||
|
||
use serde::Serialize;
|
||
use sysinfo::{Networks, Pid, ProcessRefreshKind, ProcessesToUpdate, System};
|
||
use tauri::{Manager, State};
|
||
|
||
use player::StreamProgress;
|
||
|
||
pub struct DiagState(Mutex<Inner>);
|
||
|
||
struct Inner {
|
||
sys: System,
|
||
networks: Networks,
|
||
last_net: Instant,
|
||
gpu: Option<GpuCounter>,
|
||
started: Instant,
|
||
/// The ACTIVE track's transfer, straight from the player's own
|
||
/// StreamProgress — the overlay must read download truth at the source,
|
||
/// not the UI store's window-activity-gated mirror.
|
||
current_stream: Option<(u64, Arc<StreamProgress>)>,
|
||
/// On-deck pre-buffer transfers by transfer id, mirrored from the
|
||
/// download event stream (those updates are otherwise swallowed — no
|
||
/// player, no Downloads view). The Instant is set when the transfer
|
||
/// reaches a terminal state, so finished rows linger long enough to be
|
||
/// seen and then fall off.
|
||
preloads: HashMap<u64, (PreloadDl, Option<Instant>)>,
|
||
}
|
||
|
||
/// One on-deck transfer as the overlay sees it.
|
||
#[derive(Serialize, Clone)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct PreloadDl {
|
||
pub username: String,
|
||
/// Full remote path — the frontend matches queue rows by this.
|
||
pub path: String,
|
||
pub state: String,
|
||
/// Remote queue position, when the peer reported one.
|
||
pub place: Option<u32>,
|
||
pub received: u64,
|
||
pub total: u64,
|
||
pub speed_bps: u64,
|
||
}
|
||
|
||
/// Called from the download event stream for every preload update.
|
||
pub fn note_preload(app: &tauri::AppHandle, id: u64, dl: PreloadDl, terminal: bool) {
|
||
if let Some(state) = app.try_state::<DiagState>() {
|
||
let finished = if terminal { Some(Instant::now()) } else { None };
|
||
state
|
||
.0
|
||
.lock()
|
||
.expect("diag lock")
|
||
.preloads
|
||
.insert(id, (dl, finished));
|
||
}
|
||
}
|
||
|
||
/// Called when a play path takes the transfer over: it is the CURRENT
|
||
/// track's stream from here on (note_stream reports it), not an on-deck row.
|
||
pub fn retire_preload(app: &tauri::AppHandle, id: u64) {
|
||
if let Some(state) = app.try_state::<DiagState>() {
|
||
state.0.lock().expect("diag lock").preloads.remove(&id);
|
||
}
|
||
}
|
||
|
||
/// Called by the play paths: this track streams from a peer.
|
||
pub fn note_stream(app: &tauri::AppHandle, total: u64, progress: Arc<StreamProgress>) {
|
||
if let Some(state) = app.try_state::<DiagState>() {
|
||
state.0.lock().expect("diag lock").current_stream = Some((total, progress));
|
||
}
|
||
}
|
||
|
||
/// Called by the play paths: this track plays from a complete local file.
|
||
pub fn clear_stream(app: &tauri::AppHandle) {
|
||
if let Some(state) = app.try_state::<DiagState>() {
|
||
state.0.lock().expect("diag lock").current_stream = None;
|
||
}
|
||
}
|
||
|
||
pub fn init(app: &tauri::AppHandle) {
|
||
app.manage(DiagState(Mutex::new(Inner {
|
||
sys: System::new(),
|
||
networks: Networks::new_with_refreshed_list(),
|
||
last_net: Instant::now(),
|
||
gpu: GpuCounter::open(),
|
||
started: Instant::now(),
|
||
current_stream: None,
|
||
preloads: HashMap::new(),
|
||
})));
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct DebugStats {
|
||
/// Whole-process-tree CPU, percent of the whole machine.
|
||
pub cpu_process: f32,
|
||
pub cpu_system: f32,
|
||
/// GPU 3D-engine utilization (Task-Manager-style), if counters opened.
|
||
/// MACHINE-WIDE — includes DWM, browsers, Discord, everything.
|
||
pub gpu_percent: Option<f32>,
|
||
/// Same counters, restricted to OUR process tree — the app's true GPU
|
||
/// share, matching Task Manager's per-process rows. The machine-wide
|
||
/// number above routinely reads 2× this on a busy desktop.
|
||
pub gpu_app_percent: Option<f32>,
|
||
/// Process tree working set / private commit, bytes.
|
||
pub mem_working_set: u64,
|
||
pub mem_private: u64,
|
||
pub mem_system_used: u64,
|
||
pub mem_system_total: u64,
|
||
/// System-wide network rates since the previous poll, bytes/sec.
|
||
pub net_rx_bps: f64,
|
||
pub net_tx_bps: f64,
|
||
pub process_count: usize,
|
||
pub uptime_secs: u64,
|
||
/// Active track's transfer, from the player's own progress handle.
|
||
pub dl_received: Option<u64>,
|
||
pub dl_total: Option<u64>,
|
||
pub dl_done: Option<bool>,
|
||
pub normalization_enabled: bool,
|
||
pub target_lufs: f64,
|
||
pub loudness: Option<crate::loudness::CurrentTrack>,
|
||
/// Main player's live fade-gain (1.0 = full; <1 only during a preview
|
||
/// open/close crossfade). Proves a fade never strands the volume.
|
||
pub main_fade_gain: Option<f32>,
|
||
/// The normalization gain the sink has RIGHT NOW, and the one the audio
|
||
/// thread is slewing toward, both dB. Reported as a pair because the
|
||
/// interesting state during a mid-track correction is the gap between
|
||
/// them: equal means settled, different means a correction is in flight
|
||
/// and the difference is how far it still has to travel.
|
||
pub norm_now_db: Option<f32>,
|
||
pub norm_target_db: Option<f32>,
|
||
/// The preview voices' resting volume (mirrors the app volume; 0 = muted).
|
||
pub preview_volume: Option<f32>,
|
||
/// The active preview clip's loudness-normalization gain in dB.
|
||
pub preview_norm_db: Option<f32>,
|
||
/// On-deck pre-buffer transfers, in arrival order (finished rows linger
|
||
/// ~15s so a completion is visible before it drops off).
|
||
pub preload_dl: Vec<PreloadDl>,
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn debug_stats(app: tauri::AppHandle, state: State<'_, DiagState>) -> DebugStats {
|
||
let mut inner = state.0.lock().expect("diag lock");
|
||
let inner = &mut *inner;
|
||
|
||
inner.sys.refresh_cpu_usage();
|
||
inner.sys.refresh_processes_specifics(
|
||
ProcessesToUpdate::All,
|
||
true,
|
||
ProcessRefreshKind::nothing().with_cpu().with_memory(),
|
||
);
|
||
|
||
// Our process tree: app.exe + every descendant (the WebView2 family).
|
||
let me = Pid::from_u32(std::process::id());
|
||
let mut tree = vec![me];
|
||
let mut grew = true;
|
||
while grew {
|
||
grew = false;
|
||
for (pid, proc_) in inner.sys.processes() {
|
||
if let Some(parent) = proc_.parent() {
|
||
if tree.contains(&parent) && !tree.contains(pid) {
|
||
tree.push(*pid);
|
||
grew = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let cores = inner.sys.cpus().len().max(1) as f32;
|
||
let mut cpu_process = 0.0f32;
|
||
let mut mem_working_set = 0u64;
|
||
let mut mem_private = 0u64;
|
||
for pid in &tree {
|
||
if let Some(p) = inner.sys.process(*pid) {
|
||
cpu_process += p.cpu_usage(); // % of one core
|
||
mem_working_set += p.memory();
|
||
// sysinfo's virtual_memory() is the raw address space on Windows
|
||
// (WebView reserves terabytes of it) — useless. The meaningful
|
||
// "reserved" figure is the private commit, straight from Win32.
|
||
mem_private += private_commit(pid.as_u32());
|
||
}
|
||
}
|
||
cpu_process /= cores;
|
||
|
||
inner.sys.refresh_memory();
|
||
|
||
// Network: sysinfo reports bytes since the LAST refresh per interface.
|
||
let elapsed = inner.last_net.elapsed().as_secs_f64().max(0.05);
|
||
inner.last_net = Instant::now();
|
||
inner.networks.refresh(true);
|
||
let (mut rx, mut tx) = (0u64, 0u64);
|
||
for (_, data) in inner.networks.iter() {
|
||
rx += data.received();
|
||
tx += data.transmitted();
|
||
}
|
||
|
||
let (normalization_enabled, target_lufs, loudness) = crate::loudness::snapshot(&app);
|
||
let (dl_received, dl_total, dl_done) = match &inner.current_stream {
|
||
Some((total, progress)) => {
|
||
let (received, done) = progress.received_done();
|
||
(Some(received), Some(*total), Some(done))
|
||
}
|
||
None => (None, None, None),
|
||
};
|
||
|
||
let tree_u32: Vec<u32> = tree.iter().map(|p| p.as_u32()).collect();
|
||
let gpu_sample = inner.gpu.as_mut().and_then(|g| g.sample(&tree_u32));
|
||
|
||
// On-deck transfers: drop terminal rows after they've been visible a
|
||
// while, then report the rest in arrival (transfer-id) order.
|
||
inner
|
||
.preloads
|
||
.retain(|_, v| v.1.is_none_or(|t| t.elapsed().as_secs() < 15));
|
||
let mut preload_ids: Vec<u64> = inner.preloads.keys().copied().collect();
|
||
preload_ids.sort_unstable();
|
||
let preload_dl: Vec<PreloadDl> = preload_ids
|
||
.iter()
|
||
.filter_map(|id| inner.preloads.get(id).map(|(dl, _)| dl.clone()))
|
||
.collect();
|
||
|
||
DebugStats {
|
||
cpu_process,
|
||
cpu_system: inner.sys.global_cpu_usage(),
|
||
gpu_percent: gpu_sample.map(|(sys, _)| sys),
|
||
gpu_app_percent: gpu_sample.map(|(_, app)| app),
|
||
mem_working_set,
|
||
mem_private,
|
||
mem_system_used: inner.sys.used_memory(),
|
||
mem_system_total: inner.sys.total_memory(),
|
||
net_rx_bps: rx as f64 / elapsed,
|
||
net_tx_bps: tx as f64 / elapsed,
|
||
process_count: tree.len(),
|
||
uptime_secs: inner.started.elapsed().as_secs(),
|
||
dl_received,
|
||
dl_total,
|
||
dl_done,
|
||
normalization_enabled,
|
||
target_lufs,
|
||
loudness,
|
||
norm_now_db: app
|
||
.try_state::<crate::playback::PlayerState>()
|
||
.map(|s| s.player.norm_gain_now_target_db().0),
|
||
norm_target_db: app
|
||
.try_state::<crate::playback::PlayerState>()
|
||
.map(|s| s.player.norm_gain_now_target_db().1),
|
||
preview_volume: app
|
||
.try_state::<crate::preview::PreviewState>()
|
||
.map(|s| crate::preview::target_volume(&s)),
|
||
preview_norm_db: app
|
||
.try_state::<crate::preview::PreviewState>()
|
||
.map(|s| crate::preview::active_norm_db(&s)),
|
||
main_fade_gain: app
|
||
.try_state::<crate::playback::PlayerState>()
|
||
.map(|p| p.player.fade_gain()),
|
||
preload_dl,
|
||
}
|
||
}
|
||
|
||
/// Private commit ("reserved") of one process, bytes. 0 if unreadable.
|
||
fn private_commit(pid: u32) -> u64 {
|
||
use windows::Win32::Foundation::CloseHandle;
|
||
use windows::Win32::System::ProcessStatus::{
|
||
K32GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS, PROCESS_MEMORY_COUNTERS_EX,
|
||
};
|
||
use windows::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION};
|
||
unsafe {
|
||
let Ok(handle) = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) else {
|
||
return 0;
|
||
};
|
||
let mut counters = PROCESS_MEMORY_COUNTERS_EX::default();
|
||
let ok = K32GetProcessMemoryInfo(
|
||
handle,
|
||
(&mut counters as *mut PROCESS_MEMORY_COUNTERS_EX).cast::<PROCESS_MEMORY_COUNTERS>(),
|
||
std::mem::size_of::<PROCESS_MEMORY_COUNTERS_EX>() as u32,
|
||
)
|
||
.as_bool();
|
||
let _ = CloseHandle(handle);
|
||
if ok {
|
||
counters.PrivateUsage as u64
|
||
} else {
|
||
0
|
||
}
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------ GPU (PDH) ---
|
||
|
||
/// Total 3D-engine utilization via the PDH "GPU Engine" counter set — the
|
||
/// number Task Manager labels "GPU". Instances are per-process-per-engine;
|
||
/// summing the 3D ones gives machine-wide utilization. First sample after
|
||
/// opening reads as None (PDH rate counters need two collections).
|
||
struct GpuCounter {
|
||
query: windows::Win32::System::Performance::PDH_HQUERY,
|
||
counter: windows::Win32::System::Performance::PDH_HCOUNTER,
|
||
}
|
||
|
||
// PDH handles are plain registry-ish handles; the query is only touched
|
||
// under the DiagState mutex.
|
||
unsafe impl Send for GpuCounter {}
|
||
|
||
impl GpuCounter {
|
||
fn open() -> Option<GpuCounter> {
|
||
use windows::core::w;
|
||
use windows::Win32::System::Performance::{
|
||
PdhAddEnglishCounterW, PdhOpenQueryW, PDH_HCOUNTER, PDH_HQUERY,
|
||
};
|
||
unsafe {
|
||
let mut query = PDH_HQUERY::default();
|
||
if PdhOpenQueryW(None, 0, &mut query) != 0 {
|
||
return None;
|
||
}
|
||
let mut counter = PDH_HCOUNTER::default();
|
||
let path = w!("\\GPU Engine(*engtype_3D)\\Utilization Percentage");
|
||
if PdhAddEnglishCounterW(query, path, 0, &mut counter) != 0 {
|
||
return None;
|
||
}
|
||
Some(GpuCounter { query, counter })
|
||
}
|
||
}
|
||
|
||
/// Returns (machine_total, app_tree_total). Instance names carry the
|
||
/// owning pid — "pid_1234_luid_..._engtype_3D" — so the app's share is
|
||
/// the sum over instances whose pid is in our process tree.
|
||
fn sample(&mut self, app_pids: &[u32]) -> Option<(f32, f32)> {
|
||
use windows::Win32::System::Performance::{
|
||
PdhCollectQueryData, PdhGetFormattedCounterArrayW, PDH_FMT_COUNTERVALUE_ITEM_W,
|
||
PDH_FMT_DOUBLE,
|
||
};
|
||
unsafe {
|
||
if PdhCollectQueryData(self.query) != 0 {
|
||
return None;
|
||
}
|
||
let mut buf_len: u32 = 0;
|
||
let mut count: u32 = 0;
|
||
// Size probe returns PDH_MORE_DATA; then fetch.
|
||
let _ = PdhGetFormattedCounterArrayW(
|
||
self.counter,
|
||
PDH_FMT_DOUBLE,
|
||
&mut buf_len,
|
||
&mut count,
|
||
None,
|
||
);
|
||
if buf_len == 0 {
|
||
return None;
|
||
}
|
||
let mut buf = vec![0u8; buf_len as usize];
|
||
let items = buf.as_mut_ptr() as *mut PDH_FMT_COUNTERVALUE_ITEM_W;
|
||
if PdhGetFormattedCounterArrayW(
|
||
self.counter,
|
||
PDH_FMT_DOUBLE,
|
||
&mut buf_len,
|
||
&mut count,
|
||
Some(items),
|
||
) != 0
|
||
{
|
||
return None;
|
||
}
|
||
let mut total = 0.0f64;
|
||
let mut app = 0.0f64;
|
||
for i in 0..count as usize {
|
||
let item = &*items.add(i);
|
||
let v = item.FmtValue.Anonymous.doubleValue;
|
||
total += v;
|
||
if !item.szName.is_null() {
|
||
if let Some(pid) = instance_pid(item.szName) {
|
||
if app_pids.contains(&pid) {
|
||
app += v;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Some((total.min(100.0) as f32, app.min(100.0) as f32))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Parse the pid out of a "pid_1234_luid_…" counter-instance name.
|
||
unsafe fn instance_pid(name: windows::core::PWSTR) -> Option<u32> {
|
||
let s = name.to_string().ok()?;
|
||
let rest = s.strip_prefix("pid_")?;
|
||
let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||
digits.parse().ok()
|
||
}
|
||
|
||
impl Drop for GpuCounter {
|
||
fn drop(&mut self) {
|
||
use windows::Win32::System::Performance::PdhCloseQuery;
|
||
unsafe {
|
||
let _ = PdhCloseQuery(self.query);
|
||
}
|
||
}
|
||
}
|