diff --git a/src-tauri/src/audio/decoder.rs b/src-tauri/src/audio/decoder.rs index 13585b1..03e81c5 100644 --- a/src-tauri/src/audio/decoder.rs +++ b/src-tauri/src/audio/decoder.rs @@ -9,6 +9,7 @@ //! Commands are polled between packets via `cmd_rx.try_recv()` so //! pause / stop / seek feel responsive even during long tracks. +use std::panic::{catch_unwind, AssertUnwindSafe}; use std::path::Path; use std::sync::atomic::Ordering; use std::sync::Arc; @@ -117,17 +118,70 @@ pub fn spawn_decoder_thread( // `AudioCmd::SwapProducer` when the engine rebuilds the // cpal output thread on a different device. let mut producer = producer; - decoder_loop(cmd_rx, &mut producer, shared, app, analytics_tx); + let mut panic_count = 0u32; + const MAX_DECODER_PANICS: u32 = 3; + loop { + let result = catch_unwind(AssertUnwindSafe(|| { + decoder_loop( + &cmd_rx, + &mut producer, + shared.clone(), + app.clone(), + &analytics_tx, + ); + })); + match result { + Ok(()) => break, + Err(payload) => { + panic_count += 1; + let message = panic_payload_message(payload.as_ref()); + tracing::error!(%message, panic_count, "audio decoder thread panicked"); + let _ = app.emit( + EVENT_ERROR, + ErrorPayload { + message: format!("audio decoder crashed: {message}"), + }, + ); + transition_state(&shared, &app, PlayerState::Idle, None); + if panic_count >= MAX_DECODER_PANICS { + tracing::error!( + panic_count, + "audio decoder thread panicked repeatedly, stopping recovery" + ); + let _ = app.emit( + EVENT_ERROR, + ErrorPayload { + message: "audio decoder stopped after repeated crashes" + .to_string(), + }, + ); + break; + } + let backoff_ms = 100_u64 * (1_u64 << (panic_count - 1)); + std::thread::sleep(Duration::from_millis(backoff_ms)); + } + } + } }) } +fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> String { + if let Some(message) = payload.downcast_ref::<&str>() { + return (*message).to_string(); + } + if let Some(message) = payload.downcast_ref::() { + return message.clone(); + } + "unknown panic payload".to_string() +} + /// Top-level decoder thread loop. Never returns except on `Shutdown`. fn decoder_loop( - cmd_rx: Receiver, + cmd_rx: &Receiver, producer: &mut Producer, shared: Arc, app: AppHandle, - analytics_tx: UnboundedSender, + analytics_tx: &UnboundedSender, ) { // When `play_track` returns due to a mid-decode LoadAndPlay, the // stashed command lands here so we process it before blocking on diff --git a/src-tauri/src/audio/engine.rs b/src-tauri/src/audio/engine.rs index 5a9b2a5..8f27091 100644 --- a/src-tauri/src/audio/engine.rs +++ b/src-tauri/src/audio/engine.rs @@ -136,6 +136,10 @@ pub struct AudioEngine { /// flipped by `set_wasapi_exclusive`. Used by `set_output_device` /// to preserve the mode across hot-swaps. wasapi_exclusive: std::sync::atomic::AtomicBool, + /// Whether the current output stream is actually running in + /// WASAPI Exclusive Mode. This can differ from the preference + /// when init falls back to cpal shared mode. + wasapi_exclusive_active: std::sync::atomic::AtomicBool, } impl AudioEngine { @@ -172,13 +176,14 @@ impl AudioEngine { // rows and self-send the next `LoadAndPlay`. let (analytics_tx, analytics_rx) = unbounded_channel::(); - let (output, decoder) = match spawn_output_with_mode( + let (output, decoder, wasapi_exclusive_active) = match spawn_output_with_mode( shared.clone(), app.clone(), device_name, wasapi_exclusive, ) { Ok((producer, handle)) => { + let active = handle.wasapi_exclusive; // `spawn_output_thread` returns only after the cpal // stream has opened, so `shared.sample_rate` / // `shared.channels` are already populated by the time @@ -190,17 +195,17 @@ impl AudioEngine { app.clone(), analytics_tx, ) { - Ok(join) => (Some(handle), Some(join)), + Ok(join) => (Some(handle), Some(join), active), Err(err) => { tracing::error!(?err, "failed to spawn decoder thread"); handle.stop(); - (None, None) + (None, None, false) } } } Err(err) => { tracing::warn!(?err, "failed to open audio output at startup"); - (None, None) + (None, None, false) } }; @@ -214,6 +219,7 @@ impl AudioEngine { decoder: Mutex::new(decoder), app, wasapi_exclusive: std::sync::atomic::AtomicBool::new(wasapi_exclusive), + wasapi_exclusive_active: std::sync::atomic::AtomicBool::new(wasapi_exclusive_active), }) } @@ -338,6 +344,10 @@ impl AudioEngine { } *guard = Some(handle); + self.wasapi_exclusive_active.store( + guard.as_ref().map(|h| h.wasapi_exclusive).unwrap_or(false), + std::sync::atomic::Ordering::Release, + ); // Step 6 — resume the previous track if we were playing one. if was_playing && track_id > 0 { @@ -418,6 +428,7 @@ impl AudioEngine { let (producer, handle) = spawn_output_with_mode(self.shared.clone(), self.app.clone(), active, enabled)?; + let active_mode = handle.wasapi_exclusive; if was_playing { self.cmd_tx @@ -431,6 +442,8 @@ impl AudioEngine { .send(AudioCmd::SwapProducer(producer)) .map_err(|e| AppError::Audio(format!("audio command channel closed: {e}")))?; *guard = Some(handle); + self.wasapi_exclusive_active + .store(active_mode, std::sync::atomic::Ordering::Release); if was_playing && track_id > 0 { let app = self.app.clone(); @@ -472,10 +485,11 @@ impl AudioEngine { Ok(()) } - /// Current Windows Exclusive Mode preference. Always `false` on - /// non-Windows. + /// Whether the current output stream is actually running in + /// WASAPI Exclusive Mode. Always `false` on Linux / macOS and + /// also `false` after a Windows fallback to cpal shared mode. pub fn wasapi_exclusive(&self) -> bool { - self.wasapi_exclusive - .load(std::sync::atomic::Ordering::Relaxed) + self.wasapi_exclusive_active + .load(std::sync::atomic::Ordering::Acquire) } } diff --git a/src-tauri/src/audio/output.rs b/src-tauri/src/audio/output.rs index 061d04d..0a59e4b 100644 --- a/src-tauri/src/audio/output.rs +++ b/src-tauri/src/audio/output.rs @@ -273,6 +273,10 @@ pub struct OutputHandle { /// `None` means the OS default device. Saved so a hot-swap can /// no-op when the user picks the same device again. pub device_name: Option, + /// Whether this handle is really using WASAPI Exclusive Mode. + /// The user preference can request exclusive mode, but startup may + /// fall back to cpal shared mode when the device rejects it. + pub wasapi_exclusive: bool, } impl OutputHandle { @@ -333,6 +337,7 @@ pub fn spawn_output_thread( shutdown_tx, join, device_name, + wasapi_exclusive: false, }, )), Ok(Err(err)) => { diff --git a/src-tauri/src/audio/wasapi_exclusive.rs b/src-tauri/src/audio/wasapi_exclusive.rs index 8fcdc17..56c6e33 100644 --- a/src-tauri/src/audio/wasapi_exclusive.rs +++ b/src-tauri/src/audio/wasapi_exclusive.rs @@ -90,6 +90,7 @@ pub fn spawn_exclusive_output_thread( shutdown_tx, join, device_name, + wasapi_exclusive: true, }, )), Ok(Err(err)) => { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5282f06..a596c2b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -596,7 +596,14 @@ pub fn run() { // been armed, so we can safely persist the resume point and // shut the audio engine down. WindowEvent::Destroyed => { + if window.label() != "main" { + return; + } let app = window.app_handle().clone(); + let quitting = app.state::().0.load(Ordering::Acquire); + if !quitting { + return; + } let _ = tauri::async_runtime::block_on(async move { let state = app.state::(); let engine = app.state::>();