diff --git a/crates/chanora_audio/tests/poisoned_mutex_survival.rs b/crates/chanora_audio/tests/poisoned_mutex_survival.rs new file mode 100644 index 0000000..6ba285b --- /dev/null +++ b/crates/chanora_audio/tests/poisoned_mutex_survival.rs @@ -0,0 +1,139 @@ +//! Poisoned-mutex survival test for audio callbacks (TODO-016). +//! +//! Verifies that the `unwrap_or_else(|e| e.into_inner())` recovery +//! pattern used throughout chanora_audio produces usable values +//! rather than panicking when a mutex is poisoned. + +use std::panic; +use std::sync::{Arc, Mutex}; + +/// Simulates a simple config guard behind a mutex, matching the +/// pattern used by `audio_processing_config` in the engine. +#[derive(Debug, Clone, PartialEq)] +struct DummyConfig { + gain: f32, + muted: bool, +} + +impl Default for DummyConfig { + fn default() -> Self { + Self { + gain: 1.0, + muted: false, + } + } +} + +/// Poisons a `Mutex` by panicking while holding its lock, +/// then catches the panic so the test can continue. +fn poison_mutex(mx: &Mutex) { + let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| { + let _guard = mx.lock().unwrap(); + panic!("deliberate poison"); + })); +} + +/// Helper that mirrors the exact recovery pattern used in production: +/// `lock().unwrap_or_else(|e| e.into_inner())`. +fn recover(mx: &Mutex) -> std::sync::MutexGuard<'_, T> { + mx.lock().unwrap_or_else(|e| e.into_inner()) +} + +// --------------------------------------------------------------------------- +// Test 1: Basic poison recovery returns the inner value. +// --------------------------------------------------------------------------- + +#[test] +fn poison_recovery_returns_inner_value() { + let mx = Mutex::new(DummyConfig::default()); + { + let mut g = mx.lock().unwrap(); + g.gain = 0.5; + g.muted = true; + } + poison_mutex(&mx); + assert!(mx.is_poisoned()); + + let guard = recover(&mx); + assert_eq!(guard.gain, 0.5); + assert!(guard.muted); +} + +// --------------------------------------------------------------------------- +// Test 2: Recovered guard is mutable and usable (simulates an audio +// callback writing silence to the output buffer after recovery). +// --------------------------------------------------------------------------- + +#[test] +fn recovered_guard_is_mutable() { + let mx: Mutex> = Mutex::new(vec![0.0; 256]); + { + let mut g = mx.lock().unwrap(); + g.fill(0.75); + } + poison_mutex(&mx); + + { + let mut guard = recover(&mx); + guard.fill(0.0); + } + + let guard = recover(&mx); + assert!(guard.iter().all(|&s| s == 0.0), "expected silence after recovery"); +} + +// --------------------------------------------------------------------------- +// Test 3: Multi-lock scenario — recover from a poisoned mutex, mutate +// it, and verify subsequent reads see the updated state. +// --------------------------------------------------------------------------- + +#[test] +fn recovered_state_persists_across_locks() { + let mx = Mutex::new(42u32); + poison_mutex(&mx); + + *recover(&mx) = 99; + + assert_eq!(*recover(&mx), 99); + assert!(mx.is_poisoned(), "mutex stays poisoned but remains usable"); +} + +// --------------------------------------------------------------------------- +// Test 4: Arc> pattern — mirrors the audio engine's shared +// state where multiple callbacks hold Arc clones. +// --------------------------------------------------------------------------- + +#[test] +fn shared_arc_mutex_recovery() { + let mx = Arc::new(Mutex::new(DummyConfig::default())); + { + let mut g = mx.lock().unwrap(); + g.gain = 0.8; + } + poison_mutex(&mx); + + let mx2 = Arc::clone(&mx); + let guard = mx2.lock().unwrap_or_else(|e| e.into_inner()); + assert_eq!(guard.gain, 0.8); +} + +// --------------------------------------------------------------------------- +// Test 5: Snapshot-then-clone pattern — mirrors the engine's +// `audio_processing_config_snapshot()` which clones through the guard. +// --------------------------------------------------------------------------- + +#[test] +fn snapshot_clone_through_poisoned_mutex() { + let mx = Mutex::new(DummyConfig { + gain: 0.42, + muted: true, + }); + poison_mutex(&mx); + + let snapshot = mx.lock().unwrap_or_else(|e| e.into_inner()).clone(); + assert_eq!(snapshot.gain, 0.42); + assert!(snapshot.muted); + + let snapshot2 = mx.lock().unwrap_or_else(|e| e.into_inner()).clone(); + assert_eq!(snapshot, snapshot2); +}