test(audio): add poisoned-mutex survival tests (TODO-016)

Verify mutex recovery produces usable inner value after poisoning.
5 tests covering value preservation, mutable recovery, Arc clone,
and snapshot-through-guard patterns.
This commit is contained in:
Edison Jwa
2026-06-11 12:05:44 +09:00
parent 01a4a9ed28
commit 6d2405f67a
@@ -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<T>` by panicking while holding its lock,
/// then catches the panic so the test can continue.
fn poison_mutex<T: Default>(mx: &Mutex<T>) {
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<T>(mx: &Mutex<T>) -> 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<Vec<f32>> = 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<Mutex<T>> 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);
}