fix(audio): replace Mutex unwrap with poisoned-mutex recovery (TODO-006)

Replace 42 .lock().unwrap() calls with .unwrap_or_else(|e| e.into_inner())
across 7 files. Poisoned mutex recovery prevents panics in realtime audio
callbacks. Add SAFETY comment to WebRtcFallbackVad Send impl (TODO-007).
This commit is contained in:
Edison Jwa
2026-06-11 09:46:51 +09:00
parent 13c7648a65
commit b20e6b663a
7 changed files with 48 additions and 45 deletions
+31 -31
View File
@@ -481,7 +481,7 @@ impl AudioEngine {
crate::android_voice_unit::chanora_android_abandon_audio_focus();
crate::android_voice_unit::clear_global_event_sender();
if let Some(mut unit) = android_voice_unit.lock().unwrap().take() {
if let Some(mut unit) = android_voice_unit.lock().unwrap_or_else(|e| e.into_inner()).take() {
use crate::mobile_voice_backend::MobileVoiceAudioBackend;
if let Err(e) = unit.close() {
warn!(
@@ -524,7 +524,7 @@ impl AudioEngine {
),
}
let cfg_av = android_voice_stream_config.lock().unwrap().clone();
let cfg_av = android_voice_stream_config.lock().unwrap_or_else(|e| e.into_inner()).clone();
let params = crate::mobile_voice_backend::VoiceAudioParams {
voice_out_tx: voice_out_tx.clone(),
transmit_active: transmit_gate.flag_arc(),
@@ -577,7 +577,7 @@ impl AudioEngine {
"android: bluetooth route re-engaged after backend disconnect"
);
}
*android_voice_unit.lock().unwrap() = Some(reopened);
*android_voice_unit.lock().unwrap_or_else(|e| e.into_inner()) = Some(reopened);
}
Err(e) => warn!(
target: "chanora_audio",
@@ -941,7 +941,7 @@ impl AudioEngine {
match item {
Some(v) => {
let id = SessionAudioId(v.from_client);
let mut h = handler_for_task.lock().unwrap();
let mut h = handler_for_task.lock().unwrap_or_else(|e| e.into_inner());
if let Err(e) = h.handle_packet(id, v.packet) {
debug!(target: "chanora_audio", error = %e, "decode failed");
} else {
@@ -1283,7 +1283,7 @@ impl AudioEngine {
match item {
Some(v) => {
let id = SessionAudioId(v.from_client);
let mut h = handler_for_task.lock().unwrap();
let mut h = handler_for_task.lock().unwrap_or_else(|e| e.into_inner());
let res = h.handle_packet(id, v.packet);
drop(h);
match res {
@@ -1371,12 +1371,12 @@ impl AudioEngine {
not(target_os = "android")
))]
{
let _ = self._input_stream.lock().unwrap().take();
let _ = self._output_stream.lock().unwrap().take();
let _ = self._input_stream.lock().unwrap_or_else(|e| e.into_inner()).take();
let _ = self._output_stream.lock().unwrap_or_else(|e| e.into_inner()).take();
}
#[cfg(any(target_os = "ios", target_os = "macos"))]
{
let _ = self._ios_voice_backend.lock().unwrap().take();
let _ = self._ios_voice_backend.lock().unwrap_or_else(|e| e.into_inner()).take();
}
// SDD-115 reverse-order teardown on Android:
// 1) stop Bluetooth SCO + abandon audio focus;
@@ -1390,7 +1390,7 @@ impl AudioEngine {
crate::android_voice_unit::chanora_android_stop_bluetooth_sco();
crate::android_voice_unit::chanora_android_abandon_audio_focus();
crate::android_voice_unit::clear_global_event_sender();
if let Some(mut unit) = self._android_voice_unit.lock().unwrap().take() {
if let Some(mut unit) = self._android_voice_unit.lock().unwrap_or_else(|e| e.into_inner()).take() {
use crate::mobile_voice_backend::MobileVoiceAudioBackend;
if let Err(e) = unit.close() {
warn!(
@@ -1456,7 +1456,7 @@ impl AudioEngine {
pub fn ios_restart_voice_unit(&self) -> Result<(), AudioError> {
#[cfg(any(target_os = "ios", target_os = "macos"))]
{
let mut guard = self._ios_voice_backend.lock().unwrap();
let mut guard = self._ios_voice_backend.lock().unwrap_or_else(|e| e.into_inner());
let backend = guard
.as_mut()
.ok_or_else(|| AudioError::Backend("ios voice backend not running".to_string()))?;
@@ -1479,7 +1479,7 @@ impl AudioEngine {
crate::android_voice_unit::chanora_android_abandon_audio_focus();
crate::android_voice_unit::clear_global_event_sender();
if let Some(mut unit) = self._android_voice_unit.lock().unwrap().take() {
if let Some(mut unit) = self._android_voice_unit.lock().unwrap_or_else(|e| e.into_inner()).take() {
unit.close().map_err(|e| {
AudioError::Backend(format!(
"android: failed to close Oboe voice unit during route restart: {e}"
@@ -1501,7 +1501,7 @@ impl AudioEngine {
);
}
let cfg_av = self.android_voice_stream_config.lock().unwrap().clone();
let cfg_av = self.android_voice_stream_config.lock().unwrap_or_else(|e| e.into_inner()).clone();
let params = crate::mobile_voice_backend::VoiceAudioParams {
voice_out_tx: self.voice_out_tx.clone(),
transmit_active: self.transmit_gate.flag_arc(),
@@ -1549,7 +1549,7 @@ impl AudioEngine {
);
}
let mut guard = self._android_voice_unit.lock().unwrap();
let mut guard = self._android_voice_unit.lock().unwrap_or_else(|e| e.into_inner());
*guard = Some(reopened);
Ok(())
}
@@ -1563,7 +1563,7 @@ impl AudioEngine {
pub fn ios_pause_voice_unit(&self) -> Result<(), AudioError> {
#[cfg(any(target_os = "ios", target_os = "macos"))]
{
let mut guard = self._ios_voice_backend.lock().unwrap();
let mut guard = self._ios_voice_backend.lock().unwrap_or_else(|e| e.into_inner());
let unit = guard
.as_mut()
.ok_or_else(|| AudioError::Backend("ios voice backend not running".to_string()))?;
@@ -1579,7 +1579,7 @@ impl AudioEngine {
pub fn ios_resume_voice_unit(&self) -> Result<(), AudioError> {
#[cfg(any(target_os = "ios", target_os = "macos"))]
{
let mut guard = self._ios_voice_backend.lock().unwrap();
let mut guard = self._ios_voice_backend.lock().unwrap_or_else(|e| e.into_inner());
let unit = guard
.as_mut()
.ok_or_else(|| AudioError::Backend("ios voice backend not running".to_string()))?;
@@ -1639,7 +1639,7 @@ impl AudioEngine {
/// Current audio-processing config snapshot.
pub fn audio_processing_config_snapshot(&self) -> crate::AudioProcessingConfig {
self.audio_processing_config.lock().unwrap().clone()
self.audio_processing_config.lock().unwrap_or_else(|e| e.into_inner()).clone()
}
/// Apply a voice-processing config after validating iOS invariants.
@@ -1655,7 +1655,7 @@ impl AudioEngine {
}
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
self.apply_desktop_vad_backend(&config);
let mut guard = self.audio_processing_config.lock().unwrap();
let mut guard = self.audio_processing_config.lock().unwrap_or_else(|e| e.into_inner());
*guard = config;
Ok(())
}
@@ -1681,7 +1681,7 @@ impl AudioEngine {
/// Current voice-processing stats snapshot.
pub fn audio_processing_stats(&self) -> crate::AudioProcessingStats {
let config = self.audio_processing_config.lock().unwrap().clone();
let config = self.audio_processing_config.lock().unwrap_or_else(|e| e.into_inner()).clone();
self.audio_processing_stats.snapshot(&config)
}
@@ -1816,7 +1816,7 @@ fn apply_desktop_vad_backend_to_worker(
audio_processing_stats: &crate::SharedAudioProcessingStats,
) {
if config.vad_backend != crate::VadBackend::SileroOnnx {
let mut worker_guard = silero_vad_worker.lock().unwrap();
let mut worker_guard = silero_vad_worker.lock().unwrap_or_else(|e| e.into_inner());
if worker_guard.is_some() {
info!(
target: "chanora_audio",
@@ -1841,12 +1841,12 @@ fn apply_desktop_vad_backend_to_worker(
);
let new_worker = crate::vad::silero_onnx::SileroOnnxVadWorker::try_new(&model_path);
{
let mut worker_guard = silero_vad_worker.lock().unwrap();
let mut worker_guard = silero_vad_worker.lock().unwrap_or_else(|e| e.into_inner());
*worker_guard = new_worker;
}
// Check result after releasing the lock. Re-acquire is cheap and
// ensures we log the correct state without holding the mutex.
let worker_installed = silero_vad_worker.lock().unwrap().is_some();
let worker_installed = silero_vad_worker.lock().unwrap_or_else(|e| e.into_inner()).is_some();
if worker_installed {
info!(
target: "chanora_audio",
@@ -1926,21 +1926,21 @@ mod tests {
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
#[test]
fn desktop_startup_audio_processing_state_applies_default_silero_fallback() {
let _guard = crate::vad::SILERO_MODEL_PATH_TEST_LOCK.lock().unwrap();
let _guard = crate::vad::SILERO_MODEL_PATH_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
crate::vad::clear_silero_model_path_for_test();
let (config, stats, worker) = new_desktop_audio_processing_state();
assert_eq!(
config.lock().unwrap().vad_backend,
config.lock().unwrap_or_else(|e| e.into_inner()).vad_backend,
crate::VadBackend::SileroOnnx,
"desktop startup config should keep the default Silero backend selected"
);
assert!(
worker.lock().unwrap().is_none(),
worker.lock().unwrap_or_else(|e| e.into_inner()).is_none(),
"missing startup model should not create an ONNX worker"
);
let snapshot = stats.snapshot(&config.lock().unwrap());
let snapshot = stats.snapshot(&config.lock().unwrap_or_else(|e| e.into_inner()));
assert!(
snapshot.vad_fallback_active,
"desktop startup should mark WebRTC fallback active when the default Silero worker cannot load"
@@ -1983,7 +1983,7 @@ mod tests {
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
#[test]
fn desktop_capture_silero_selection_reports_fallback_when_worker_unavailable() {
let _guard = crate::vad::SILERO_MODEL_PATH_TEST_LOCK.lock().unwrap();
let _guard = crate::vad::SILERO_MODEL_PATH_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
crate::vad::clear_silero_model_path_for_test();
let gate = crate::ptt::AudioTransmitGate::new(false);
@@ -2025,7 +2025,7 @@ mod tests {
}
capture.ingest(&voiced);
let snapshot = stats.snapshot(&config.lock().unwrap());
let snapshot = stats.snapshot(&config.lock().unwrap_or_else(|e| e.into_inner()));
assert_eq!(snapshot.vad_backend, crate::VadBackend::SileroOnnx);
assert!(
snapshot.vad_fallback_active,
@@ -2132,7 +2132,7 @@ mod tests {
capture.process_10ms_capture_frame(&voiced);
let snapshot = stats.snapshot(&config.lock().unwrap());
let snapshot = stats.snapshot(&config.lock().unwrap_or_else(|e| e.into_inner()));
assert!(
snapshot.vad_fallback_active,
"stale Silero worker output should report active WebRTC fallback"
@@ -2725,7 +2725,7 @@ where
.build_input_stream(
*config,
move |data: &[T], _: &cpal::InputCallbackInfo| {
let mut s = state.lock().unwrap();
let mut s = state.lock().unwrap_or_else(|e| e.into_inner());
s.ingest(data);
},
move |e| {
@@ -2816,7 +2816,7 @@ where
// only for the duration of fill_buffer; the inbound
// forwarder uses handle_packet which is queue-fast.
{
let mut h = handler.lock().unwrap();
let mut h = handler.lock().unwrap_or_else(|e| e.into_inner());
h.fill_buffer(&mut scratch[..needed]);
}
@@ -2835,7 +2835,7 @@ where
// Resample 48 kHz stereo → device-rate ×
// device-channels with continuity across
// callback boundaries.
let mut state = resample_state.lock().unwrap();
let mut state = resample_state.lock().unwrap_or_else(|e| e.into_inner());
let mut pos = state.pos;
let mut last_l = state.last_l;
let mut last_r = state.last_r;