From 488a4224a511c3b89fcbac239e0fcd9fb8d2056b Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Fri, 20 Mar 2026 22:23:56 +0100 Subject: [PATCH 1/3] fix(alsa): reentrancy and partial IO handling --- CHANGELOG.md | 2 + src/host/alsa/mod.rs | 119 +++++++++++++++++++++---------------------- 2 files changed, 61 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 005e6581f..9c00a260f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **ALSA**: Device disconnection now stops the stream with `StreamError::DeviceNotAvailable` instead of looping. - **ALSA**: Polling errors trigger underrun recovery instead of looping. - **ALSA**: Try to resume from hardware after a system suspend. +- **ALSA**: Loop partial reads and writes to completion. +- **ALSA**: Prevent reentrancy issues with non-reentrant plugins and devices. - **ASIO**: `Device::driver`, `asio_streams`, and `current_callback_flag` are no longer `pub`. - **ASIO**: Timestamps now include driver-reported hardware latency. - **CoreAudio**: Timestamps now include device latency and safety offset. diff --git a/src/host/alsa/mod.rs b/src/host/alsa/mod.rs index 02b1d9ca3..e4154aac7 100644 --- a/src/host/alsa/mod.rs +++ b/src/host/alsa/mod.rs @@ -86,6 +86,9 @@ mod enumerate; const DEFAULT_DEVICE: &str = "default"; +// Some ALSA plugins (e.g. alsaequal, certain USB drivers) are not reentrant. +static ALSA_OPEN_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); + // TODO: Not yet defined in rust-lang/libc crate const LIBC_ENOTSUPP: libc::c_int = 524; @@ -371,9 +374,11 @@ impl Device { } } - let handle = match alsa::pcm::PCM::new(&self.pcm_id, stream_type, true) - .map_err(|e| (e, e.errno())) - { + let open_result = { + let _guard = ALSA_OPEN_MUTEX.lock().unwrap(); + alsa::pcm::PCM::new(&self.pcm_id, stream_type, true).map_err(|e| (e, e.errno())) + }; + let handle = match open_result { Err((_, libc::ENOENT)) | Err((_, libc::EPERM)) | Err((_, libc::ENODEV)) @@ -410,7 +415,8 @@ impl Device { // Pre-compute a period-sized buffer filled with silence values. let period_frames = period_samples / conf.channels as usize; - let period_bytes = period_samples * sample_format.sample_size(); + let frame_size = sample_format.sample_size() * conf.channels as usize; + let period_bytes = period_frames * frame_size; let mut silence_template = vec![0u8; period_bytes].into_boxed_slice(); // Only fill buffer for unsigned formats that don't have a zero value for silence. @@ -426,6 +432,7 @@ impl Device { conf, period_samples, period_frames, + frame_size, silence_template, can_pause, creation_instant, @@ -472,21 +479,24 @@ impl Device { &self, stream_t: alsa::Direction, ) -> Result, SupportedStreamConfigsError> { - let pcm = - match alsa::pcm::PCM::new(&self.pcm_id, stream_t, true).map_err(|e| (e, e.errno())) { - Err((_, libc::ENOENT)) - | Err((_, libc::EPERM)) - | Err((_, libc::ENODEV)) - | Err((_, LIBC_ENOTSUPP)) => { - return Err(SupportedStreamConfigsError::DeviceNotAvailable) - } - Err((_, libc::EBUSY)) | Err((_, libc::EAGAIN)) => { - return Err(SupportedStreamConfigsError::DeviceBusy) - } - Err((_, libc::EINVAL)) => return Err(SupportedStreamConfigsError::InvalidArgument), - Err((e, _)) => return Err(e.into()), - Ok(pcm) => pcm, - }; + let open_result = { + let _guard = ALSA_OPEN_MUTEX.lock().unwrap(); + alsa::pcm::PCM::new(&self.pcm_id, stream_t, true).map_err(|e| (e, e.errno())) + }; + let pcm = match open_result { + Err((_, libc::ENOENT)) + | Err((_, libc::EPERM)) + | Err((_, libc::ENODEV)) + | Err((_, LIBC_ENOTSUPP)) => { + return Err(SupportedStreamConfigsError::DeviceNotAvailable) + } + Err((_, libc::EBUSY)) | Err((_, libc::EAGAIN)) => { + return Err(SupportedStreamConfigsError::DeviceBusy) + } + Err((_, libc::EINVAL)) => return Err(SupportedStreamConfigsError::InvalidArgument), + Err((e, _)) => return Err(e.into()), + Ok(pcm) => pcm, + }; let hw_params = alsa::pcm::HwParams::any(&pcm)?; @@ -711,6 +721,7 @@ struct StreamInner { // Cached values for performance in audio callback hot path period_samples: usize, period_frames: usize, + frame_size: usize, silence_template: Box<[u8]>, #[allow(dead_code)] @@ -939,33 +950,6 @@ fn try_resume(channel: &alsa::PCM) -> Result { } } -/// Validate the result of a `writei` or `readi` call and map ALSA errors to [`StreamError`]. -#[inline] -fn check_io_result( - result: Result, - period_frames: usize, - channel: &alsa::PCM, - direction: alsa::Direction, -) -> Result<(), StreamError> { - match result { - Ok(n) if n != period_frames => Err(BackendSpecificError { - description: format!( - "partial {}: expected {period_frames} frames, transferred {n}", - match direction { - alsa::Direction::Capture => "read", - alsa::Direction::Playback => "write", - } - ), - } - .into()), - Ok(_) => Ok(()), - Err(err) if err.errno() == libc::EPIPE => Err(StreamError::BufferUnderrun), - Err(err) if err.errno() == libc::ESTRPIPE => try_resume(channel).map(|_| ()), - Err(err) if err.errno() == libc::ENODEV => Err(StreamError::DeviceNotAvailable), - Err(err) => Err(err.into()), - } -} - enum Poll { Pending, Ready { @@ -1047,13 +1031,20 @@ fn process_input( delay_frames: usize, data_callback: &mut (dyn FnMut(&Data, &InputCallbackInfo) + Send + 'static), ) -> Result<(), StreamError> { - let result = stream.channel.io_bytes().readi(buffer); - check_io_result( - result, - stream.period_frames, - &stream.channel, - alsa::Direction::Capture, - )?; + let mut frames_read = 0; + while frames_read < stream.period_frames { + match stream + .channel + .io_bytes() + .readi(&mut buffer[frames_read * stream.frame_size..]) + { + Ok(n) => frames_read += n, + Err(err) if err.errno() == libc::EPIPE => return Err(StreamError::BufferUnderrun), + Err(err) if err.errno() == libc::ESTRPIPE => return Err(StreamError::BufferUnderrun), + Err(err) if err.errno() == libc::ENODEV => return Err(StreamError::DeviceNotAvailable), + Err(err) => return Err(err.into()), + } + } let data = buffer.as_mut_ptr() as *mut (); let data = unsafe { Data::from_parts(data, stream.period_samples, stream.sample_format) }; let callback = if stream.use_hw_timestamps { @@ -1108,13 +1099,21 @@ fn process_output( data_callback(&mut data, &info); } - let result = stream.channel.io_bytes().writei(buffer); - check_io_result( - result, - stream.period_frames, - &stream.channel, - alsa::Direction::Playback, - ) + let mut frames_written = 0; + while frames_written < stream.period_frames { + match stream + .channel + .io_bytes() + .writei(&buffer[frames_written * stream.frame_size..]) + { + Ok(n) => frames_written += n, + Err(err) if err.errno() == libc::EPIPE => return Err(StreamError::BufferUnderrun), + Err(err) if err.errno() == libc::ESTRPIPE => return Err(StreamError::BufferUnderrun), + Err(err) if err.errno() == libc::ENODEV => return Err(StreamError::DeviceNotAvailable), + Err(err) => return Err(err.into()), + } + } + Ok(()) } // Use hardware timestamps from ALSA. From 9929529352758d7d8c2e8597db31b28347d19845 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Fri, 20 Mar 2026 22:50:58 +0100 Subject: [PATCH 2/3] fix(alsa): don't leak poison into other audio thraeds --- src/host/alsa/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/host/alsa/mod.rs b/src/host/alsa/mod.rs index e4154aac7..514fe2aa6 100644 --- a/src/host/alsa/mod.rs +++ b/src/host/alsa/mod.rs @@ -375,7 +375,7 @@ impl Device { } let open_result = { - let _guard = ALSA_OPEN_MUTEX.lock().unwrap(); + let _guard = ALSA_OPEN_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); alsa::pcm::PCM::new(&self.pcm_id, stream_type, true).map_err(|e| (e, e.errno())) }; let handle = match open_result { @@ -480,7 +480,7 @@ impl Device { stream_t: alsa::Direction, ) -> Result, SupportedStreamConfigsError> { let open_result = { - let _guard = ALSA_OPEN_MUTEX.lock().unwrap(); + let _guard = ALSA_OPEN_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); alsa::pcm::PCM::new(&self.pcm_id, stream_t, true).map_err(|e| (e, e.errno())) }; let pcm = match open_result { From e1c90af2cd4ad92ba639a5fd4cebd84624a8d17b Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Fri, 20 Mar 2026 23:07:10 +0100 Subject: [PATCH 3/3] style(alsa): handle EPIPE and ESTRPIPE together in read/write loop --- src/host/alsa/mod.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/host/alsa/mod.rs b/src/host/alsa/mod.rs index 514fe2aa6..379eee2f5 100644 --- a/src/host/alsa/mod.rs +++ b/src/host/alsa/mod.rs @@ -1039,8 +1039,11 @@ fn process_input( .readi(&mut buffer[frames_read * stream.frame_size..]) { Ok(n) => frames_read += n, - Err(err) if err.errno() == libc::EPIPE => return Err(StreamError::BufferUnderrun), - Err(err) if err.errno() == libc::ESTRPIPE => return Err(StreamError::BufferUnderrun), + Err(err) if err.errno() == libc::EPIPE || err.errno() == libc::ESTRPIPE => { + // EPIPE = xrun, ESTRPIPE = hardware suspend. Both require prepare()+restart; + // attempting resume mid-loop with a partial transfer in the ring buffer is unsafe. + return Err(StreamError::BufferUnderrun); + } Err(err) if err.errno() == libc::ENODEV => return Err(StreamError::DeviceNotAvailable), Err(err) => return Err(err.into()), } @@ -1107,8 +1110,11 @@ fn process_output( .writei(&buffer[frames_written * stream.frame_size..]) { Ok(n) => frames_written += n, - Err(err) if err.errno() == libc::EPIPE => return Err(StreamError::BufferUnderrun), - Err(err) if err.errno() == libc::ESTRPIPE => return Err(StreamError::BufferUnderrun), + Err(err) if err.errno() == libc::EPIPE || err.errno() == libc::ESTRPIPE => { + // EPIPE = xrun, ESTRPIPE = hardware suspend. Both require prepare()+restart; + // attempting resume mid-loop with a partial transfer in the ring buffer is unsafe. + return Err(StreamError::BufferUnderrun); + } Err(err) if err.errno() == libc::ENODEV => return Err(StreamError::DeviceNotAvailable), Err(err) => return Err(err.into()), }