fix(analysis): CPU seed queue, single waveform emit, and log URL redaction

Serialize heavy PCM seeding through a dedicated queue with optional priority
for the current track. Emit waveform-updated once per completed seed, fix
Lucky Mix waveform refresh tokens, redact Subsonic URLs in logs, and align
hot-cache prefetch with the queued path.
This commit is contained in:
cucadmuh
2026-04-26 20:59:33 +03:00
committed by Maxim Isaev
parent 5cb233e1c9
commit 218aa00718
7 changed files with 1045 additions and 315 deletions
+548 -78
View File
@@ -9,7 +9,7 @@ pub(crate) mod logging;
#[cfg(target_os = "windows")]
mod taskbar_win;
use std::collections::HashMap;
use std::collections::{HashMap, VecDeque};
use std::sync::{Arc, Mutex, OnceLock, RwLock};
use std::sync::atomic::{AtomicBool, Ordering};
@@ -60,10 +60,425 @@ fn sync_cancel_flags() -> &'static Mutex<HashMap<String, Arc<AtomicBool>>> {
FLAGS.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Tracks analysis backfill jobs already running per track_id.
fn analysis_backfill_inflight() -> &'static Mutex<std::collections::HashSet<String>> {
static SET: OnceLock<Mutex<std::collections::HashSet<String>>> = OnceLock::new();
SET.get_or_init(|| Mutex::new(std::collections::HashSet::new()))
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AnalysisBackfillEnqueueKind {
/// New job at the tail of the queue.
NewBack,
/// New job for the currently playing track (head).
NewFront,
/// Same track was already waiting; moved to head with the latest URL.
ReorderedFront,
/// Low-priority duplicate while the track is already queued or running.
DuplicateSkipped,
/// High-priority request but that track is already being downloaded+seeded.
RunningSkipped,
}
#[derive(Default)]
struct AnalysisBackfillQueueState {
deque: VecDeque<(String, String)>,
/// Set while this `track_id` is inside `analysis_backfill_download_and_seed` (not in deque).
in_progress: Option<String>,
}
impl AnalysisBackfillQueueState {
fn is_reserved(&self, tid: &str) -> bool {
self.in_progress.as_deref() == Some(tid)
|| self.deque.iter().any(|(t, _)| t.as_str() == tid)
}
fn try_pop_next(&mut self) -> Option<(String, String)> {
let (tid, url) = self.deque.pop_front()?;
self.in_progress = Some(tid.clone());
Some((tid, url))
}
fn finish_job(&mut self, tid: &str) {
if self.in_progress.as_deref() == Some(tid) {
self.in_progress = None;
}
}
fn enqueue(
&mut self,
tid: String,
url: String,
high_priority: bool,
) -> AnalysisBackfillEnqueueKind {
let tref = tid.as_str();
if self.is_reserved(tref) {
if !high_priority {
return AnalysisBackfillEnqueueKind::DuplicateSkipped;
}
if self.in_progress.as_deref() == Some(tref) {
return AnalysisBackfillEnqueueKind::RunningSkipped;
}
self.deque.retain(|(t, _)| t != &tid);
self.deque.push_front((tid, url));
return AnalysisBackfillEnqueueKind::ReorderedFront;
}
if high_priority {
self.deque.push_front((tid, url));
AnalysisBackfillEnqueueKind::NewFront
} else {
self.deque.push_back((tid, url));
AnalysisBackfillEnqueueKind::NewBack
}
}
}
struct AnalysisBackfillShared {
state: Mutex<AnalysisBackfillQueueState>,
wake_tx: tokio::sync::mpsc::UnboundedSender<()>,
}
impl AnalysisBackfillShared {
fn ping_worker(&self) {
let _ = self.wake_tx.send(());
}
}
static ANALYSIS_BACKFILL: OnceLock<Arc<AnalysisBackfillShared>> = OnceLock::new();
/// Lazily spawns the single backfill worker (first caller supplies `AppHandle`).
fn analysis_backfill_shared(app: &tauri::AppHandle) -> Arc<AnalysisBackfillShared> {
ANALYSIS_BACKFILL
.get_or_init(|| {
let (wake_tx, wake_rx) = tokio::sync::mpsc::unbounded_channel();
let shared = Arc::new(AnalysisBackfillShared {
state: Mutex::new(AnalysisBackfillQueueState::default()),
wake_tx,
});
let app = app.clone();
let sh = shared.clone();
tauri::async_runtime::spawn(analysis_backfill_worker_loop(app, sh, wake_rx));
shared
})
.clone()
}
async fn analysis_backfill_download_and_seed(
app: &tauri::AppHandle,
track_id: &str,
url: &str,
) -> Result<bool, String> {
let client = reqwest::Client::builder()
.user_agent(subsonic_wire_user_agent())
.timeout(std::time::Duration::from_secs(120))
.build()
.map_err(|e| e.to_string())?;
let response = client.get(url).send().await.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status().as_u16()));
}
let bytes = response.bytes().await.map_err(|e| e.to_string())?;
if bytes.is_empty() {
return Err("empty response".to_string());
}
enqueue_analysis_seed(app, track_id, &bytes).await
}
async fn analysis_backfill_worker_loop(
app: tauri::AppHandle,
shared: Arc<AnalysisBackfillShared>,
mut wake_rx: tokio::sync::mpsc::UnboundedReceiver<()>,
) {
loop {
if wake_rx.recv().await.is_none() {
break;
}
while let Some((track_id, url)) = {
let mut st = shared
.state
.lock()
.unwrap_or_else(|e| e.into_inner());
st.try_pop_next()
} {
crate::app_deprintln!("[analysis] backfill worker: start track_id={}", track_id);
let result = analysis_backfill_download_and_seed(&app, &track_id, &url).await;
match &result {
Ok(has_loudness) => crate::app_deprintln!(
"[analysis] backfill ready: {} (has_loudness={})",
track_id,
has_loudness
),
Err(e) => crate::app_eprintln!("[analysis] backfill failed for {}: {}", track_id, e),
}
let mut st = shared
.state
.lock()
.unwrap_or_else(|e| e.into_inner());
st.finish_job(&track_id);
}
}
}
fn analysis_backfill_is_current_track(app: &tauri::AppHandle, track_id: &str) -> bool {
app.try_state::<crate::audio::AudioEngine>()
.is_some_and(|e| crate::audio::analysis_track_id_is_current_playback(&e, track_id))
}
// ─── Full-track waveform + loudness: single CPU worker (mirrors HTTP backfill queue) ─
// One `spawn_blocking` decode at a time; current playback is high-priority (front + reorder).
// Same `track_id` queued again merges waiters onto one job; while decode runs, same-id
// submitters attach to `running` followers so they all get the same outcome.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AnalysisCpuSeedEnqueueKind {
NewBack,
NewFront,
ReorderedFront,
RunningFollower,
MergedQueued,
}
struct AnalysisCpuSeedJob {
track_id: String,
bytes: Vec<u8>,
waiters: Vec<tokio::sync::oneshot::Sender<Result<analysis_cache::SeedFromBytesOutcome, String>>>,
}
struct AnalysisCpuSeedQueueState {
deque: VecDeque<AnalysisCpuSeedJob>,
/// Decode in progress — same-id callers wait here for the same outcome.
running: Option<(
String,
Arc<Mutex<Vec<tokio::sync::oneshot::Sender<Result<analysis_cache::SeedFromBytesOutcome, String>>>>>,
)>,
}
impl AnalysisCpuSeedQueueState {
fn enqueue(
&mut self,
track_id: String,
bytes: Vec<u8>,
high_priority: bool,
) -> (
AnalysisCpuSeedEnqueueKind,
tokio::sync::oneshot::Receiver<Result<analysis_cache::SeedFromBytesOutcome, String>>,
) {
let (done_tx, done_rx) = tokio::sync::oneshot::channel();
let tid = track_id.as_str();
if let Some((rtid, followers)) = &self.running {
if rtid == tid {
followers
.lock()
.unwrap_or_else(|e| e.into_inner())
.push(done_tx);
return (AnalysisCpuSeedEnqueueKind::RunningFollower, done_rx);
}
}
if let Some(pos) = self.deque.iter().position(|j| j.track_id == track_id) {
let mut job = self.deque.remove(pos).unwrap();
job.bytes = bytes;
job.waiters.push(done_tx);
let kind = if high_priority {
self.deque.push_front(job);
AnalysisCpuSeedEnqueueKind::ReorderedFront
} else {
self.deque.push_back(job);
AnalysisCpuSeedEnqueueKind::MergedQueued
};
return (kind, done_rx);
}
let job = AnalysisCpuSeedJob {
track_id: track_id.clone(),
bytes,
waiters: vec![done_tx],
};
let kind = if high_priority {
self.deque.push_front(job);
AnalysisCpuSeedEnqueueKind::NewFront
} else {
self.deque.push_back(job);
AnalysisCpuSeedEnqueueKind::NewBack
};
(kind, done_rx)
}
}
struct AnalysisCpuSeedShared {
state: Mutex<AnalysisCpuSeedQueueState>,
wake_tx: tokio::sync::mpsc::UnboundedSender<()>,
}
impl Default for AnalysisCpuSeedQueueState {
fn default() -> Self {
Self {
deque: VecDeque::new(),
running: None,
}
}
}
impl AnalysisCpuSeedShared {
fn ping_worker(&self) {
let _ = self.wake_tx.send(());
}
}
static ANALYSIS_CPU_SEED: OnceLock<Arc<AnalysisCpuSeedShared>> = OnceLock::new();
fn analysis_cpu_seed_shared(app: &tauri::AppHandle) -> Arc<AnalysisCpuSeedShared> {
ANALYSIS_CPU_SEED
.get_or_init(|| {
let (wake_tx, wake_rx) = tokio::sync::mpsc::unbounded_channel();
let shared = Arc::new(AnalysisCpuSeedShared {
state: Mutex::new(AnalysisCpuSeedQueueState::default()),
wake_tx,
});
let app = app.clone();
let sh = shared.clone();
tauri::async_runtime::spawn(analysis_cpu_seed_worker_loop(app, sh, wake_rx));
shared
})
.clone()
}
/// HTTP backfill + CPU seed queue sizes (debug log only — `app_deprintln!`).
fn emit_analysis_queue_snapshot_line() {
let http = if let Some(arc) = ANALYSIS_BACKFILL.get() {
let st = arc.state.lock().unwrap_or_else(|e| e.into_inner());
format!(
"http_backfill={{queued:{} download_active:{:?}}}",
st.deque.len(),
st.in_progress.as_deref()
)
} else {
"http_backfill={{not_started}}".to_string()
};
let cpu = if let Some(arc) = ANALYSIS_CPU_SEED.get() {
let st = arc.state.lock().unwrap_or_else(|e| e.into_inner());
let queued_jobs = st.deque.len();
let pending_in_queued_jobs: usize = st.deque.iter().map(|j| j.waiters.len()).sum();
let (decoding_tid, decoding_extra_waiters) = match &st.running {
Some((tid, fl)) => (
Some(tid.as_str()),
fl.lock().map(|g| g.len()).unwrap_or(0),
),
None => (None, 0usize),
};
format!(
"cpu_seed={{queued_jobs:{} pending_channels_in_queue:{} decoding_tid:{:?} extra_waiters_same_id:{}}}",
queued_jobs,
pending_in_queued_jobs,
decoding_tid,
decoding_extra_waiters
)
} else {
"cpu_seed={{not_started}}".to_string()
};
crate::app_deprintln!(
"[analysis] queue_snapshot interval_s=60 note=queues_in_memory_cleared_on_app_restart | {http} | {cpu}"
);
}
async fn analysis_queue_snapshot_loop() {
emit_analysis_queue_snapshot_line();
loop {
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
emit_analysis_queue_snapshot_line();
}
}
async fn analysis_cpu_seed_worker_loop(
app: tauri::AppHandle,
shared: Arc<AnalysisCpuSeedShared>,
mut wake_rx: tokio::sync::mpsc::UnboundedReceiver<()>,
) {
loop {
if wake_rx.recv().await.is_none() {
break;
}
loop {
let (job, followers) = {
let mut st = shared.state.lock().unwrap_or_else(|e| e.into_inner());
let Some(j) = st.deque.pop_front() else {
break;
};
let fl = Arc::new(Mutex::new(Vec::new()));
st.running = Some((j.track_id.clone(), fl.clone()));
(j, fl)
};
let tid_log = job.track_id.clone();
let app2 = app.clone();
let tid = job.track_id.clone();
let bytes = job.bytes;
let outcome = tokio::task::spawn_blocking(move || {
analysis_cache::seed_from_bytes_execute(&app2, &tid, &bytes)
})
.await
.unwrap_or_else(|e| Err(format!("cpu-seed spawn_blocking: {e}")));
let mut extra = followers
.lock()
.unwrap_or_else(|e| e.into_inner())
.drain(..)
.collect::<Vec<_>>();
for tx in job.waiters {
let _ = tx.send(outcome.clone());
}
for tx in extra.drain(..) {
let _ = tx.send(outcome.clone());
}
{
let mut st = shared.state.lock().unwrap_or_else(|e| e.into_inner());
st.running = None;
}
let ok = outcome.as_ref().map(|o| *o == analysis_cache::SeedFromBytesOutcome::Upserted).unwrap_or(false);
crate::app_deprintln!(
"[analysis] cpu-seed worker: done track_id={} upserted={}",
tid_log,
ok
);
}
}
}
/// Submit full-buffer analysis; serializes with other producers. `high_priority` mirrors
/// HTTP backfill head insertion for the currently playing track.
///
/// Emits `analysis:waveform-updated` once here when the DB row is ready (Upserted or cache hit),
/// so `audio` and other callers do not duplicate IPC.
pub(crate) async fn submit_analysis_cpu_seed(
app: tauri::AppHandle,
track_id: String,
bytes: Vec<u8>,
high_priority: bool,
) -> Result<analysis_cache::SeedFromBytesOutcome, String> {
let shared = analysis_cpu_seed_shared(&app);
let rx = {
let mut st = shared.state.lock().unwrap_or_else(|e| e.into_inner());
let (kind, rx) = st.enqueue(track_id.clone(), bytes, high_priority);
crate::app_deprintln!("[analysis] cpu-seed submit: kind={kind:?} high_priority={high_priority}");
drop(st);
shared.ping_worker();
rx
};
let outcome = match rx.await {
Ok(res) => res?,
Err(_) => return Err("cpu-seed: result channel dropped".to_string()),
};
if matches!(
outcome,
analysis_cache::SeedFromBytesOutcome::Upserted
| analysis_cache::SeedFromBytesOutcome::SkippedWaveformCacheHit
) {
let _ = app.emit(
"analysis:waveform-updated",
WaveformUpdatedPayload {
track_id: track_id.clone(),
is_partial: false,
},
);
}
Ok(outcome)
}
/// Holds the live system-tray icon handle. `None` means the tray is currently hidden/removed.
@@ -1314,6 +1729,15 @@ fn analysis_enqueue_seed_from_url(
if track_id.trim().is_empty() || url.trim().is_empty() {
return Ok(());
}
if let Some(engine) = app.try_state::<crate::audio::AudioEngine>() {
if crate::audio::ranged_loudness_backfill_should_defer(&engine, &track_id) {
crate::app_deprintln!(
"[analysis] backfill skip track_id={} reason=ranged_playback_will_seed",
track_id
);
return Ok(());
}
}
if let Some(cache) = app.try_state::<analysis_cache::AnalysisCache>() {
if cache.get_latest_loudness_for_track(&track_id)?.is_some() {
crate::app_deprintln!(
@@ -1323,48 +1747,34 @@ fn analysis_enqueue_seed_from_url(
return Ok(());
}
}
{
let mut inflight = analysis_backfill_inflight()
let tid_log = track_id.clone();
let high_priority = analysis_backfill_is_current_track(&app, &track_id);
let shared = analysis_backfill_shared(&app);
let kind = {
let mut st = shared
.state
.lock()
.map_err(|_| "analysis backfill lock poisoned".to_string())?;
if inflight.contains(&track_id) {
return Ok(());
st.enqueue(track_id, url, high_priority)
};
match kind {
AnalysisBackfillEnqueueKind::NewBack | AnalysisBackfillEnqueueKind::NewFront => {
shared.ping_worker();
crate::app_deprintln!(
"[analysis] backfill enqueued: track_id={} position={}",
tid_log,
if high_priority { "front" } else { "back" }
);
}
inflight.insert(track_id.clone());
AnalysisBackfillEnqueueKind::ReorderedFront => {
shared.ping_worker();
crate::app_deprintln!(
"[analysis] backfill bumped to front (current track) track_id={}",
tid_log
);
}
AnalysisBackfillEnqueueKind::DuplicateSkipped | AnalysisBackfillEnqueueKind::RunningSkipped => {}
}
let app_clone = app.clone();
tauri::async_runtime::spawn(async move {
crate::app_deprintln!("[analysis] backfill queued: {}", track_id);
let result = async {
let client = reqwest::Client::builder()
.user_agent(subsonic_wire_user_agent())
.timeout(std::time::Duration::from_secs(120))
.build()
.map_err(|e| e.to_string())?;
let response = client.get(&url).send().await.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status().as_u16()));
}
let bytes = response.bytes().await.map_err(|e| e.to_string())?;
if bytes.is_empty() {
return Err("empty response".to_string());
}
let has_loudness = enqueue_analysis_seed(&app_clone, &track_id, &bytes)?;
Ok::<bool, String>(has_loudness)
}
.await;
match result {
Ok(has_loudness) => crate::app_deprintln!(
"[analysis] backfill ready: {} (has_loudness={})",
track_id,
has_loudness
),
Err(e) => crate::app_eprintln!("[analysis] backfill failed for {}: {}", track_id, e),
}
if let Ok(mut inflight) = analysis_backfill_inflight().lock() {
inflight.remove(&track_id);
}
});
Ok(())
}
@@ -1388,42 +1798,29 @@ async fn stream_to_file(response: reqwest::Response, dest_path: &std::path::Path
Ok(())
}
fn enqueue_analysis_seed(app: &tauri::AppHandle, track_id: &str, bytes: &[u8]) -> Result<bool, String> {
let outcome = analysis_cache::seed_from_bytes(app, track_id, bytes).map_err(|e| {
async fn enqueue_analysis_seed(app: &tauri::AppHandle, track_id: &str, bytes: &[u8]) -> Result<bool, String> {
let high = analysis_backfill_is_current_track(app, track_id);
let outcome = submit_analysis_cpu_seed(
app.clone(),
track_id.to_string(),
bytes.to_vec(),
high,
)
.await
.map_err(|e| {
crate::app_eprintln!("[analysis] failed to seed {}: {}", track_id, e);
e
})?;
if outcome == analysis_cache::SeedFromBytesOutcome::Upserted {
let _ = app.emit(
"analysis:waveform-updated",
WaveformUpdatedPayload {
track_id: track_id.to_string(),
is_partial: false,
},
);
}
let has_loudness = app
.try_state::<analysis_cache::AnalysisCache>()
.and_then(|cache| cache.get_latest_loudness_for_track(track_id).ok().flatten())
.is_some();
// SkippedConcurrent gets logged with the actual outcome so the line doesn't
// read "seed result has_loudness=false" when in fact another path is mid-seed
// and will publish the row in seconds.
if outcome == analysis_cache::SeedFromBytesOutcome::SkippedConcurrent {
crate::app_deprintln!(
"[analysis] seed deferred to in-flight peer track_id={} bytes={} has_loudness_now={}",
track_id,
bytes.len(),
has_loudness
);
} else {
crate::app_deprintln!(
"[analysis] seed result track_id={} bytes={} has_loudness={}",
track_id,
bytes.len(),
has_loudness
);
}
crate::app_deprintln!(
"[analysis] seed result track_id={} bytes={} has_loudness={} outcome={outcome:?}",
track_id,
bytes.len(),
has_loudness
);
Ok(has_loudness)
}
@@ -1439,7 +1836,7 @@ async fn enqueue_analysis_seed_from_file(
if bytes.is_empty() {
return;
}
let _ = enqueue_analysis_seed(app, track_id, &bytes);
let _ = enqueue_analysis_seed(app, track_id, &bytes).await;
}
/// Downloads a single track to the app's offline cache directory.
@@ -2031,12 +2428,32 @@ async fn download_track_hot_cache(
.await
.map(|m| m.len())
.unwrap_or(0);
crate::app_deprintln!(
"[hot-cache] download disk_hit track_id={} server_id={} bytes={}",
track_id,
server_id,
size
);
// Disk hit: still seed analysis, but do not block the command (full-file read); the
// prefetch worker runs invokes sequentially.
let app_seed = app.clone();
let tid = track_id.clone();
let fp = file_path.clone();
tokio::spawn(async move {
enqueue_analysis_seed_from_file(&app_seed, &tid, &fp).await;
});
return Ok(HotCacheDownloadResult {
path: path_str,
size,
});
}
crate::app_deprintln!(
"[hot-cache] download http_start track_id={} server_id={}",
track_id,
server_id
);
let client = reqwest::Client::builder()
.user_agent(subsonic_wire_user_agent())
.timeout(std::time::Duration::from_secs(120))
@@ -2058,12 +2475,23 @@ async fn download_track_hot_cache(
.await
.map_err(|e| e.to_string())?;
enqueue_analysis_seed_from_file(&app, &track_id, &file_path).await;
let app_seed = app.clone();
let tid = track_id.clone();
let fp = file_path.clone();
tokio::spawn(async move {
enqueue_analysis_seed_from_file(&app_seed, &tid, &fp).await;
});
let size = tokio::fs::metadata(&file_path)
.await
.map(|m| m.len())
.unwrap_or(0);
crate::app_deprintln!(
"[hot-cache] download http_done track_id={} server_id={} bytes={}",
track_id,
server_id,
size
);
Ok(HotCacheDownloadResult {
path: path_str,
size,
@@ -2096,12 +2524,30 @@ async fn promote_stream_cache_to_hot_cache(
.await
.map(|m| m.len())
.unwrap_or(0);
crate::app_deprintln!(
"[hot-cache] promote disk_hit track_id={} server_id={} bytes={}",
track_id,
server_id,
size
);
let app_seed = app.clone();
let tid = track_id.clone();
let fp = file_path.clone();
tokio::spawn(async move {
enqueue_analysis_seed_from_file(&app_seed, &tid, &fp).await;
});
return Ok(Some(HotCacheDownloadResult { path: path_str, size }));
}
let bytes = match audio::take_stream_completed_for_url(&state, &url) {
Some(b) => b,
None => return Ok(None),
None => {
crate::app_deprintln!(
"[hot-cache] promote skip track_id={} reason=no_completed_stream_for_url",
track_id
);
return Ok(None);
}
};
let part_path = file_path.with_extension(format!("{suffix}.part"));
@@ -2113,12 +2559,18 @@ async fn promote_stream_cache_to_hot_cache(
.await
.map_err(|e| e.to_string())?;
let _ = enqueue_analysis_seed(&app, &track_id, &bytes);
let _ = enqueue_analysis_seed(&app, &track_id, &bytes).await;
let size = tokio::fs::metadata(&file_path)
.await
.map(|m| m.len())
.unwrap_or(0);
crate::app_deprintln!(
"[hot-cache] promote from_stream track_id={} server_id={} bytes={}",
track_id,
server_id,
size
);
Ok(Some(HotCacheDownloadResult { path: path_str, size }))
}
@@ -2159,11 +2611,20 @@ async fn delete_hot_cache_track(
app: tauri::AppHandle,
) -> Result<(), String> {
let file_path = std::path::PathBuf::from(&local_path);
if file_path.exists() {
let existed = file_path.exists();
if existed {
tokio::fs::remove_file(&file_path)
.await
.map_err(|e| e.to_string())?;
}
crate::app_deprintln!(
"[hot-cache] delete file existed={} path_suffix={}",
existed,
file_path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("?")
);
let boundary = resolve_hot_cache_root(custom_dir, &app)?;
@@ -2193,11 +2654,17 @@ async fn delete_hot_cache_track(
#[tauri::command]
async fn purge_hot_cache(custom_dir: Option<String>, app: tauri::AppHandle) -> Result<(), String> {
let dir = resolve_hot_cache_root(custom_dir, &app)?;
if dir.exists() {
let existed = dir.exists();
if existed {
tokio::fs::remove_dir_all(&dir)
.await
.map_err(|e| e.to_string())?;
}
crate::app_deprintln!(
"[hot-cache] purge root_existed={} dir={}",
existed,
dir.display()
);
Ok(())
}
@@ -3886,6 +4353,9 @@ pub fn run() {
app.manage(cache);
}
// Periodic analysis queue sizes (debug logging mode only).
tauri::async_runtime::spawn(analysis_queue_snapshot_loop());
// ── Custom title bar on Linux ─────────────────────────────────
// Remove OS window decorations on all Linux so the React TitleBar
// can take over. The frontend checks is_tiling_wm() to decide