From e97f6c4f75dbc07ef157fde956e7e0fe2864b12f Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 10 May 2026 14:52:28 +0200 Subject: [PATCH 01/22] fix: make CoreAudio and JACK streams start manually This prevents error callbacks from being called before the stream handle is returned to the user. --- CHANGELOG.md | 2 ++ src/host/coreaudio/macos/device.rs | 30 ++++--------------- src/host/coreaudio/macos/mod.rs | 11 ++----- src/host/jack/stream.rs | 48 +++++++++--------------------- 4 files changed, 25 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 194a97ce3..beec992f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,6 +90,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **CoreAudio**: Stream error callback now receives `ErrorKind::DeviceChanged` on iOS when headphones are unplugged. - **CoreAudio**: User timeouts are now respected when building a stream. +- **CoreAudio**: Streams no longer start automatically on creation; call `play()` manually. - **CoreAudio (iOS)**: `default_input_config()` and `default_output_config()` now prefer 48 kHz, then 44.1 kHz, then the maximum supported sample rate, instead of always taking the maximum. - **JACK**: Timestamps now use the precise hardware deadline. @@ -103,6 +104,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 port-connection failures. - **JACK**: Stream error callback now receives `ErrorKind::RealtimeDenied` once if the process callback is not running at real-time scheduling priority. +- **JACK**: Streams no longer start automatically on creation; call `play()` manually. - **Linux/BSD**: Default host in order from first to last available now is: PipeWire, PulseAudio, ALSA. - **WASAPI**: Raise `windows` dependency lower bound to 0.61. diff --git a/src/host/coreaudio/macos/device.rs b/src/host/coreaudio/macos/device.rs index 702c09d0d..b264408c6 100644 --- a/src/host/coreaudio/macos/device.rs +++ b/src/host/coreaudio/macos/device.rs @@ -825,9 +825,9 @@ impl Device { audio_unit.initialize()?; let inner_arc = Arc::new(Mutex::new(StreamInner { - playing: true, + playing: false, audio_unit, - device_id: self.audio_device_id, + _device_id: self.audio_device_id, _loopback_device: loopback_aggregate, })); let weak_inner = Arc::downgrade(&inner_arc); @@ -836,16 +836,7 @@ impl Device { weak_inner, error_callback_disconnect, )?); - let stream = Stream::new(inner_arc, monitor); - - stream - .inner - .lock() - .map_err(|_| Error::with_message(ErrorKind::StreamInvalidated, "stream lock poisoned"))? - .audio_unit - .start()?; - - Ok(stream) + Ok(Stream::new(inner_arc, monitor)) } fn build_output_stream_raw( @@ -936,9 +927,9 @@ impl Device { audio_unit.initialize()?; let inner_arc = Arc::new(Mutex::new(StreamInner { - playing: true, + playing: false, audio_unit, - device_id: self.audio_device_id, + _device_id: self.audio_device_id, _loopback_device: None, })); let weak_inner = Arc::downgrade(&inner_arc); @@ -951,16 +942,7 @@ impl Device { error_callback, )?) }; - let stream = Stream::new(inner_arc, monitor); - - stream - .inner - .lock() - .map_err(|_| Error::with_message(ErrorKind::StreamInvalidated, "stream lock poisoned"))? - .audio_unit - .start()?; - - Ok(stream) + Ok(Stream::new(inner_arc, monitor)) } } diff --git a/src/host/coreaudio/macos/mod.rs b/src/host/coreaudio/macos/mod.rs index e9c447bd4..620a8e2d4 100644 --- a/src/host/coreaudio/macos/mod.rs +++ b/src/host/coreaudio/macos/mod.rs @@ -247,14 +247,9 @@ impl DefaultOutputMonitor { struct StreamInner { playing: bool, audio_unit: AudioUnit, - // Track the device with which the audio unit was spawned. - // - // We must do this so that we can avoid changing the device sample rate if there is already - // a stream associated with the device. - #[allow(dead_code)] - device_id: AudioDeviceID, - /// Manage the lifetime of the aggregate device used - /// for loopback recording + // Track the device with which the audio unit was spawned + _device_id: AudioDeviceID, + /// Manage the lifetime of the aggregate device used for loopback recording _loopback_device: Option, } diff --git a/src/host/jack/stream.rs b/src/host/jack/stream.rs index b24d92657..76e82edd6 100644 --- a/src/host/jack/stream.rs +++ b/src/host/jack/stream.rs @@ -47,7 +47,7 @@ impl Stream { ports.push(port); } - let playing = Arc::new(AtomicBool::new(true)); + let playing = Arc::new(AtomicBool::new(false)); let error_callback_ptr: ErrorCallbackArc = Arc::new(Mutex::new(error_callback)); let input_process_handler = LocalProcessHandler::new( @@ -98,7 +98,7 @@ impl Stream { ports.push(port); } - let playing = Arc::new(AtomicBool::new(true)); + let playing = Arc::new(AtomicBool::new(false)); let error_callback_ptr: ErrorCallbackArc = Arc::new(Mutex::new(error_callback)); let output_process_handler = LocalProcessHandler::new( @@ -139,11 +139,8 @@ impl Stream { /// On error, connections that were made before the failure are rolled back on a best-effort /// basis so the JACK graph is left unchanged. pub fn connect_to_system_outputs(&mut self) -> Result<(), Error> { - let system_ports = self.async_client.as_client().ports( - Some("system:playback_.*"), - None, - jack::PortFlags::empty(), - ); + let client = self.async_client.as_client(); + let system_ports = client.ports(Some("system:playback_.*"), None, jack::PortFlags::empty()); let n_our = self.output_port_names.len(); let n_sys = system_ports.len(); @@ -160,18 +157,11 @@ impl Stream { for (i, (our_port, system_port)) in self.output_port_names.iter().zip(&system_ports).enumerate() { - if let Err(e) = self - .async_client - .as_client() - .connect_ports_by_name(our_port, system_port) - { + if let Err(e) = client.connect_ports_by_name(our_port, system_port) { for (prev_our, prev_sys) in self.output_port_names[..i].iter().zip(&system_ports[..i]) { - let _ = self - .async_client - .as_client() - .disconnect_ports_by_name(prev_our, prev_sys); + let _ = client.disconnect_ports_by_name(prev_our, prev_sys); } return Err(Error::with_message( @@ -195,11 +185,8 @@ impl Stream { /// On error, connections that were made before the failure are rolled back on a best-effort /// basis so the JACK graph is left unchanged. pub fn connect_to_system_inputs(&mut self) -> Result<(), Error> { - let system_ports = self.async_client.as_client().ports( - Some("system:capture_.*"), - None, - jack::PortFlags::empty(), - ); + let client = self.async_client.as_client(); + let system_ports = client.ports(Some("system:capture_.*"), None, jack::PortFlags::empty()); let n_our = self.input_port_names.len(); let n_sys = system_ports.len(); @@ -216,18 +203,11 @@ impl Stream { for (i, (system_port, our_port)) in system_ports.iter().zip(&self.input_port_names).enumerate() { - if let Err(e) = self - .async_client - .as_client() - .connect_ports_by_name(system_port, our_port) - { + if let Err(e) = client.connect_ports_by_name(system_port, our_port) { for (prev_sys, prev_our) in system_ports[..i].iter().zip(&self.input_port_names[..i]) { - let _ = self - .async_client - .as_client() - .disconnect_ports_by_name(prev_sys, prev_our); + let _ = client.disconnect_ports_by_name(prev_sys, prev_our); } return Err(Error::with_message( @@ -329,6 +309,10 @@ impl jack::ProcessHandler for LocalProcessHandler { client: &jack::Client, process_scope: &jack::ProcessScope, ) -> jack::Control { + if !self.playing.load(Ordering::Relaxed) { + return jack::Control::Continue; + } + #[cfg(feature = "realtime")] { if !self.rt_checked { @@ -396,10 +380,6 @@ impl jack::ProcessHandler for LocalProcessHandler { } } - if !self.playing.load(Ordering::Relaxed) { - return jack::Control::Continue; - } - // This should be equal to self.buffer_size, but the implementation will // work even if it is less. Will panic in `temp_buffer_to_data` if greater. let current_frame_count = process_scope.n_frames() as usize; From 7abfcd8c79e7868e06e5be01d32ba5cef59e168b Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 10 May 2026 14:55:04 +0200 Subject: [PATCH 02/22] fix(pulseaudio): synchronize latency thread in new_record --- src/host/pulseaudio/stream.rs | 88 +++++++++++++++++++---------------- 1 file changed, 49 insertions(+), 39 deletions(-) diff --git a/src/host/pulseaudio/stream.rs b/src/host/pulseaudio/stream.rs index 766c76c61..7ce45492f 100644 --- a/src/host/pulseaudio/stream.rs +++ b/src/host/pulseaudio/stream.rs @@ -1,7 +1,7 @@ use std::{ sync::{ atomic::{self, AtomicBool, AtomicU64}, - Arc, Condvar, Mutex, + Arc, Barrier, Condvar, Mutex, Ordering, }, time::{Duration, Instant}, }; @@ -44,7 +44,7 @@ impl LatencyHandle { // Signal cancellation and wake the thread immediately fn cancel(&self) { - self.cancel.store(true, atomic::Ordering::Relaxed); + self.cancel.store(true, Ordering::Relaxed); self.notify(); } } @@ -188,8 +188,8 @@ impl Stream { // Interpolate the latency based on elapsed time since the last // poll: as audio plays, the DAC drains the buffer at a constant // rate, so the latency decreases linearly between polls. - let stored_latency = latency_clone.load(atomic::Ordering::Relaxed); - let poll_usec = poll_clone.load(atomic::Ordering::Relaxed); + let stored_latency = latency_clone.load(Ordering::Relaxed); + let poll_usec = poll_clone.load(Ordering::Relaxed); // Cap to LATENCY_MAX_INTERVAL: the linear-drain assumption is only valid for that // window, and a stale poll_usec (e.g. after cork/uncork where timing_info blocks) // would otherwise saturate latency to zero. @@ -245,7 +245,7 @@ impl Stream { // The barrier prevents the worker and latency threads from firing callbacks before the // caller has received the Stream handle. - let ready = std::sync::Arc::new(std::sync::Barrier::new(3)); + let ready = Arc::new(Barrier::new(3)); let ready_worker = ready.clone(); let driver_handle = std::thread::spawn(move || { @@ -254,7 +254,7 @@ impl Stream { // A server playback error is expected when the client // closes their stream. No need to report it back to // the client. - if !cancel_driver.load(atomic::Ordering::Relaxed) { + if !cancel_driver.load(Ordering::Relaxed) { emit_error(&error_callback_clone, Error::from(e)); } } @@ -270,7 +270,7 @@ impl Stream { let latency_handle = std::thread::spawn(move || { ready_latency.wait(); loop { - if cancel_thread.load(atomic::Ordering::Relaxed) { + if cancel_thread.load(Ordering::Relaxed) { break; } @@ -284,7 +284,7 @@ impl Stream { let poll_since_epoch = Instant::now().saturating_duration_since(start).as_micros() as u64; - poll_clone.store(poll_since_epoch, atomic::Ordering::Relaxed); + poll_clone.store(poll_since_epoch, Ordering::Relaxed); store_latency( &latency_clone, @@ -349,8 +349,8 @@ impl Stream { // Interpolate the latency based on elapsed time since the last poll: as audio records, // the ADC fills the buffer at a constant rate, so the latency increases linearly // between polls. - let stored_latency = latency_clone.load(atomic::Ordering::Relaxed); - let poll_usec = poll_clone.load(atomic::Ordering::Relaxed); + let stored_latency = latency_clone.load(Ordering::Relaxed); + let poll_usec = poll_clone.load(Ordering::Relaxed); // Cap to LATENCY_MAX_INTERVAL: the linear-fill assumption is only valid for that // window, and a stale poll_usec (e.g. after cork/uncork where timing_info blocks) // would otherwise keep inflating the interpolated latency up to the cap. @@ -395,40 +395,50 @@ impl Stream { let stream_clone = stream.clone(); let latency_clone = current_latency_micros.clone(); let poll_clone = last_poll_micros.clone(); - let latency_handle = std::thread::spawn(move || loop { - if cancel_thread.load(atomic::Ordering::Relaxed) { - break; - } - let timing_info = match block_on(stream_clone.timing_info()) { - Ok(timing_info) => timing_info, - Err(e) => { - error_callback(Error::from(e)); + // The barrier prevents the worker and latency threads from firing callbacks before the + // caller has received the Stream handle. + let ready = Arc::new(Barrier::new(2)); + let ready_latency = ready.clone(); + + let latency_handle = std::thread::spawn(move || { + ready_latency.wait(); + loop { + if cancel_thread.load(Ordering::Relaxed) { break; } - }; - let poll_since_epoch = - Instant::now().saturating_duration_since(start).as_micros() as u64; - poll_clone.store(poll_since_epoch, atomic::Ordering::Relaxed); - - store_latency( - &latency_clone, - sample_spec, - timing_info.source_usec, - timing_info.write_offset, - timing_info.read_offset, - ); - - // Wait until woken by a read/play/pause/drop event or until LATENCY_MAX_INTERVAL. - let (lock, cvar) = &*update_thread; - let Ok(guard) = lock.lock() else { break }; - let (mut guard, _) = cvar - .wait_timeout_while(guard, LATENCY_MAX_INTERVAL, |notified| !*notified) - .unwrap_or_else(|e| e.into_inner()); - *guard = false; + let timing_info = match block_on(stream_clone.timing_info()) { + Ok(timing_info) => timing_info, + Err(e) => { + error_callback(Error::from(e)); + break; + } + }; + + let poll_since_epoch = + Instant::now().saturating_duration_since(start).as_micros() as u64; + poll_clone.store(poll_since_epoch, Ordering::Relaxed); + + store_latency( + &latency_clone, + sample_spec, + timing_info.source_usec, + timing_info.write_offset, + timing_info.read_offset, + ); + + // Wait until woken by a read/play/pause/drop event or until LATENCY_MAX_INTERVAL. + let (lock, cvar) = &*update_thread; + let Ok(guard) = lock.lock() else { break }; + let (mut guard, _) = cvar + .wait_timeout_while(guard, LATENCY_MAX_INTERVAL, |notified| !*notified) + .unwrap_or_else(|e| e.into_inner()); + *guard = false; + } }); + ready.wait(); Ok(Self { inner: StreamInner::Record(stream, start, handle), workers: vec![latency_handle], @@ -450,6 +460,6 @@ fn store_latency( latency_micros.store( latency.as_micros().try_into().unwrap_or(u64::MAX), - atomic::Ordering::Relaxed, + Ordering::Relaxed, ); } From af0fec939f5583f979338498c1c7251e31c296c6 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Mon, 11 May 2026 20:48:49 +0200 Subject: [PATCH 03/22] refactor(pipewire): unify init handshake and error reporting --- src/host/pipewire/device.rs | 305 +++++++++++++++++------------------- src/host/pipewire/stream.rs | 6 +- 2 files changed, 152 insertions(+), 159 deletions(-) diff --git a/src/host/pipewire/device.rs b/src/host/pipewire/device.rs index deb148c72..e621ff31d 100644 --- a/src/host/pipewire/device.rs +++ b/src/host/pipewire/device.rs @@ -319,8 +319,7 @@ impl DeviceTrait for Device { { let (pw_play_tx, pw_play_rx) = pw::channel::channel::(); - let (pw_init_tx, pw_init_rx) = std::sync::mpsc::channel::(); - let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel::<()>(0); + let (init_tx, init_rx) = std::sync::mpsc::channel::>(); let device = self.clone(); let wait_timeout = timeout.unwrap_or(Duration::from_secs(2)); let initial_quantum = match config.buffer_size { @@ -336,18 +335,7 @@ impl DeviceTrait for Device { let _pw = PwInitGuard::new(); let properties = device.pw_properties(DeviceDirection::Input, &config); - let Ok(StreamData { - mainloop, - listener, - stream, - context, - core, - core_monitor, - error_callback, - pending_device_changed, - invalidated, - is_default_device, - }) = super::stream::connect_input( + let stream_data = match super::stream::connect_input( super::stream::ConnectParams { config, properties, @@ -357,55 +345,59 @@ impl DeviceTrait for Device { }, data_callback, error_callback, - ) - else { - let _ = pw_init_tx.send(false); - return; + ) { + Ok(d) => d, + Err(e) => { + let _ = init_tx.send(Err(Error::with_message( + ErrorKind::DeviceNotAvailable, + format!("PipeWire stream connection failed: {e}"), + ))); + return; + } }; - let _ = pw_init_tx.send(true); - - // Wait until the caller has received the Stream handle before running the - // mainloop or invoking any callbacks. If the caller timed out and dropped - // ready_tx, exit cleanly. - if ready_rx.recv().is_err() { - return; - } - let default_monitor = - if let Some(key) = device.default_metadata_key() { - match core.get_registry_rc() { - Ok(registry) => Some(DefaultDeviceMonitor::new( - registry, - key, - error_callback.clone(), - invalidated, - pending_device_changed, - )), - Err(e) => { - emit_error( - &error_callback, - Error::with_message( - ErrorKind::BackendError, - format!("PipeWire: could not acquire registry; device change notifications will be unavailable: {e}"), - ), - ); - None - } + let StreamData { + mainloop, + listener, + stream, + context, + core, + core_monitor, + error_callback, + pending_device_changed, + invalidated, + is_default_device, + } = stream_data; + + let default_monitor = if let Some(key) = device.default_metadata_key() { + match core.get_registry_rc() { + Ok(registry) => Some(DefaultDeviceMonitor::new( + registry, + key, + error_callback.clone(), + invalidated, + pending_device_changed, + )), + Err(e) => { + let _ = init_tx.send(Err(Error::with_message( + ErrorKind::BackendError, + format!("PipeWire: could not acquire registry: {e}"), + ))); + return; } - } else { - None - }; + } + } else { + None + }; is_default_device.store(default_monitor.is_some(), Ordering::Relaxed); - let stream = stream.clone(); + let stream_clone = stream.clone(); let mainloop_rc1 = mainloop.clone(); - - #[cfg(feature = "realtime")] - let error_callback_rt = error_callback.clone(); + let error_callback_cmd = error_callback.clone(); let _receiver = pw_play_rx.attach(mainloop.loop_(), move |play| match play { StreamCommand::Toggle(state) => { - if let Err(e) = stream.set_active(state) { + if let Err(e) = stream_clone.set_active(state) { emit_error( - &error_callback, + &error_callback_cmd, Error::with_message( ErrorKind::StreamInvalidated, format!("PipeWire: set_active({state}) failed: {e}"), @@ -414,9 +406,9 @@ impl DeviceTrait for Device { } } StreamCommand::Stop => { - if let Err(e) = stream.disconnect() { + if let Err(e) = stream_clone.disconnect() { emit_error( - &error_callback, + &error_callback_cmd, Error::with_message( ErrorKind::StreamInvalidated, format!("PipeWire: stream disconnect failed: {e}"), @@ -427,15 +419,23 @@ impl DeviceTrait for Device { } }); + // All synchronous setup is complete. Signal the main thread. + if init_tx.send(Ok(())).is_err() { + // Main thread timed out and dropped init_rx; exit cleanly. + return; + } + + // RT priority promotion runs after the signal so it doesn't block stream creation. #[cfg(feature = "realtime")] if let Err(e) = audio_thread_priority::promote_current_thread_to_real_time( - device.quantum, + device.quantum as u32, device.rate, ) { - emit_error(&error_callback_rt, Error::from(e)); + emit_error(&error_callback, Error::from(e)); } mainloop.run(); + drop(listener); drop(default_monitor); drop(core_monitor); @@ -443,28 +443,25 @@ impl DeviceTrait for Device { drop(context); }) .map_err(|e| { - Error::with_message(ErrorKind::ResourceExhausted, format!("failed to create thread: {e}")) + Error::with_message( + ErrorKind::ResourceExhausted, + format!("failed to create thread: {e}"), + ) })?; - match pw_init_rx.recv_timeout(wait_timeout) { - Ok(true) => { - let stream = Stream { - handle: Some(handle), - controller: pw_play_tx, - last_quantum, - start, - }; - let _ = ready_tx.send(()); - Ok(stream) - } - Ok(false) => Err(Error::with_message( - ErrorKind::UnsupportedConfig, - "stream configuration rejected by PipeWire", - )), - Err(_) => Err(Error::with_message( + + init_rx.recv_timeout(wait_timeout).unwrap_or_else(|_| { + Err(Error::with_message( ErrorKind::DeviceNotAvailable, "PipeWire timed out", - )), - } + )) + })?; + + Ok(Stream { + handle: Some(handle), + controller: pw_play_tx, + last_quantum, + start, + }) } fn build_output_stream_raw( @@ -481,8 +478,7 @@ impl DeviceTrait for Device { { let (pw_play_tx, pw_play_rx) = pw::channel::channel::(); - let (pw_init_tx, pw_init_rx) = std::sync::mpsc::channel::(); - let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel::<()>(0); + let (init_tx, init_rx) = std::sync::mpsc::channel::>(); let device = self.clone(); let wait_timeout = timeout.unwrap_or(Duration::from_secs(2)); let initial_quantum = match config.buffer_size { @@ -498,18 +494,7 @@ impl DeviceTrait for Device { let _pw = PwInitGuard::new(); let properties = device.pw_properties(DeviceDirection::Output, &config); - let Ok(StreamData { - mainloop, - listener, - stream, - context, - core, - core_monitor, - error_callback, - pending_device_changed, - invalidated, - is_default_device, - }) = super::stream::connect_output( + let stream_data = match super::stream::connect_output( super::stream::ConnectParams { config, properties, @@ -519,56 +504,59 @@ impl DeviceTrait for Device { }, data_callback, error_callback, - ) - else { - let _ = pw_init_tx.send(false); - return; + ) { + Ok(d) => d, + Err(e) => { + let _ = init_tx.send(Err(Error::with_message( + ErrorKind::DeviceNotAvailable, + format!("PipeWire stream connection failed: {e}"), + ))); + return; + } }; - let _ = pw_init_tx.send(true); - - // Wait until the caller has received the Stream handle before running the - // mainloop or invoking any callbacks. If the caller timed out and dropped - // ready_tx, exit cleanly. - if ready_rx.recv().is_err() { - return; - } - - let default_monitor = - if let Some(key) = device.default_metadata_key() { - match core.get_registry_rc() { - Ok(registry) => Some(DefaultDeviceMonitor::new( - registry, - key, - error_callback.clone(), - invalidated, - pending_device_changed, - )), - Err(e) => { - emit_error( - &error_callback, - Error::with_message( - ErrorKind::BackendError, - format!("PipeWire: could not acquire registry; device change notifications will be unavailable: {e}"), - ), - ); - None - } + let StreamData { + mainloop, + listener, + stream, + context, + core, + core_monitor, + error_callback, + pending_device_changed, + invalidated, + is_default_device, + } = stream_data; + + let default_monitor = if let Some(key) = device.default_metadata_key() { + match core.get_registry_rc() { + Ok(registry) => Some(DefaultDeviceMonitor::new( + registry, + key, + error_callback.clone(), + invalidated, + pending_device_changed, + )), + Err(e) => { + let _ = init_tx.send(Err(Error::with_message( + ErrorKind::BackendError, + format!("PipeWire: could not acquire registry: {e}"), + ))); + return; } - } else { - None - }; + } + } else { + None + }; is_default_device.store(default_monitor.is_some(), Ordering::Relaxed); - let stream = stream.clone(); + let stream_clone = stream.clone(); let mainloop_rc1 = mainloop.clone(); - - #[cfg(feature = "realtime")] - let error_callback_rt = error_callback.clone(); + let error_callback_cmd = error_callback.clone(); let _receiver = pw_play_rx.attach(mainloop.loop_(), move |play| match play { StreamCommand::Toggle(state) => { - if let Err(e) = stream.set_active(state) { + if let Err(e) = stream_clone.set_active(state) { emit_error( - &error_callback, + &error_callback_cmd, Error::with_message( ErrorKind::StreamInvalidated, format!("PipeWire: set_active({state}) failed: {e}"), @@ -577,9 +565,9 @@ impl DeviceTrait for Device { } } StreamCommand::Stop => { - if let Err(e) = stream.disconnect() { + if let Err(e) = stream_clone.disconnect() { emit_error( - &error_callback, + &error_callback_cmd, Error::with_message( ErrorKind::StreamInvalidated, format!("PipeWire: stream disconnect failed: {e}"), @@ -590,12 +578,19 @@ impl DeviceTrait for Device { } }); + // All synchronous setup is complete. Signal the main thread. + if init_tx.send(Ok(())).is_err() { + // Main thread timed out and dropped init_rx; exit cleanly. + return; + } + + // RT priority promotion runs after the signal so it doesn't block stream creation. #[cfg(feature = "realtime")] if let Err(e) = audio_thread_priority::promote_current_thread_to_real_time( - device.quantum, + device.quantum as u32, device.rate, ) { - emit_error(&error_callback_rt, Error::from(e)); + emit_error(&error_callback, Error::from(e)); } mainloop.run(); @@ -606,28 +601,24 @@ impl DeviceTrait for Device { drop(context); }) .map_err(|e| { - Error::with_message(ErrorKind::ResourceExhausted, format!("failed to create thread: {e}")) + Error::with_message( + ErrorKind::ResourceExhausted, + format!("failed to create thread: {e}"), + ) })?; - match pw_init_rx.recv_timeout(wait_timeout) { - Ok(true) => { - let stream = Stream { - handle: Some(handle), - controller: pw_play_tx, - last_quantum, - start, - }; - let _ = ready_tx.send(()); - Ok(stream) - } - Ok(false) => Err(Error::with_message( - ErrorKind::UnsupportedConfig, - "stream configuration rejected by PipeWire", - )), - Err(_) => Err(Error::with_message( + init_rx.recv_timeout(wait_timeout).unwrap_or_else(|_| { + Err(Error::with_message( ErrorKind::DeviceNotAvailable, "PipeWire timed out", - )), - } + )) + })?; + + Ok(Stream { + handle: Some(handle), + controller: pw_play_tx, + last_quantum, + start, + }) } } diff --git a/src/host/pipewire/stream.rs b/src/host/pipewire/stream.rs index d4e93569c..4034024ae 100644 --- a/src/host/pipewire/stream.rs +++ b/src/host/pipewire/stream.rs @@ -626,6 +626,7 @@ where if n_channels == 0 { return; // format not yet negotiated by param_changed } + if let Some(mut buffer) = stream.dequeue_buffer() { // Read the requested frame count before mutably borrowing datas_mut(). let requested = buffer.requested() as usize; @@ -685,7 +686,7 @@ where // RT_PROCESS is intentionally absent: with add_local_listener the process callback always // runs on this mainloop thread, not the separate data-loop thread RT_PROCESS creates. - // The mainloop thread is promoted to RT by the caller (device.rs) before mainloop.run(). + // The worker thread is promoted to RT after signalling the main thread (see device.rs). let flags = pw::stream::StreamFlags::AUTOCONNECT | pw::stream::StreamFlags::MAP_BUFFERS; stream.connect(pw::spa::utils::Direction::Output, None, flags, &mut params)?; @@ -858,6 +859,7 @@ where if n_channels == 0 { return; // format not yet negotiated by param_changed } + if let Some(mut buffer) = stream.dequeue_buffer() { let datas = buffer.datas_mut(); if datas.is_empty() { @@ -899,7 +901,7 @@ where // RT_PROCESS is intentionally absent: with add_local_listener the process callback always // runs on this mainloop thread, not the separate data-loop thread RT_PROCESS creates. - // The mainloop thread is promoted to RT by the caller (device.rs) before mainloop.run(). + // The worker thread is promoted to RT after signalling the main thread (see device.rs). let flags = pw::stream::StreamFlags::AUTOCONNECT | pw::stream::StreamFlags::MAP_BUFFERS; stream.connect(pw::spa::utils::Direction::Input, None, flags, &mut params)?; From 87db3afbb7555712e4aa31570bac57f7fed2a27c Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Mon, 11 May 2026 22:41:52 +0200 Subject: [PATCH 04/22] fix(asio): block events until Stream handle returned --- CHANGELOG.md | 1 + src/host/asio/stream.rs | 62 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index beec992f8..a08e11a5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -158,6 +158,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **ASIO**: Poisoned stream locks now return `ErrorKind::StreamInvalidated` instead of panicking. - **ASIO**: Output buffers are now zero-filled before the callback runs. - **ASIO**: Fix `driver.sample_rate()` failures at stream creation being silently ignored. +- **ASIO**: Fix callbacks firing before `build_*_stream` returns the `Stream` handle. - **CoreAudio**: Fix default output streams silently stopping when the system default output device is unplugged; they now reroute automatically or report `ErrorKind::DeviceNotAvailable`. - **CoreAudio**: Fix undefined behaviour and silent failure in loopback device creation. diff --git a/src/host/asio/stream.rs b/src/host/asio/stream.rs index e64459f57..82615e918 100644 --- a/src/host/asio/stream.rs +++ b/src/host/asio/stream.rs @@ -4,7 +4,7 @@ extern crate num_traits; use std::{ sync::{ atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}, - Arc, Mutex, + Arc, Condvar, Mutex, }, time::Duration, }; @@ -54,9 +54,7 @@ impl TimeBase { pub struct Stream { playing: Arc, - // Ensure the `Driver` does not terminate until the last stream is dropped. driver: Arc, - #[allow(dead_code)] asio_streams: Arc>, callback_id: sys::BufferCallbackId, driver_event_callback_id: sys::DriverEventCallbackId, @@ -159,14 +157,20 @@ impl Device { .unwrap_or(0), )); + // `stream_ready` is signalled just before `Ok(stream)` so that any driver events fired + // during `driver.start()` are not delivered until the caller holds the handle. + let stream_ready: Arc<(Mutex, Condvar)> = + Arc::new((Mutex::new(false), Condvar::new())); + let stream_playing = Arc::new(AtomicBool::new(false)); + let driver_event_callback_id = self.add_event_callback( &driver, error_callback, Arc::clone(&hardware_input_latency), true, + Arc::clone(&stream_ready), ); - let stream_playing = Arc::new(AtomicBool::new(false)); let playing = Arc::clone(&stream_playing); let asio_streams = self.asio_streams.clone(); let mut current_buffer_size = buffer_size as i32; @@ -178,7 +182,7 @@ impl Device { // Set the input callback. // This is most performance critical part of the ASIO bindings. let callback_id = driver.add_callback(move |callback_info| unsafe { - // If not playing return early. + // If not playing, return early. if !playing.load(Ordering::Acquire) { return; } @@ -403,6 +407,15 @@ impl Device { driver.start().map_err(build_stream_err)?; + // Signal the event callback that the Stream handle is about to be returned. Any driver + // events that fired during `driver.start()` will unblock and be delivered to the caller + // after this point. + { + let (lock, cvar) = &*stream_ready; + *lock.lock().unwrap() = true; + cvar.notify_all(); + } + Ok(Stream { playing: stream_playing, driver, @@ -473,14 +486,20 @@ impl Device { .unwrap_or(0), )); + // `stream_ready` is signalled just before `Ok(stream)` so that any driver events fired + // during `driver.start()` are not delivered until the caller holds the handle. + let stream_ready: Arc<(Mutex, Condvar)> = + Arc::new((Mutex::new(false), Condvar::new())); + let stream_playing = Arc::new(AtomicBool::new(false)); + let driver_event_callback_id = self.add_event_callback( &driver, error_callback, Arc::clone(&hardware_output_latency), false, + Arc::clone(&stream_ready), ); - let stream_playing = Arc::new(AtomicBool::new(false)); let playing = Arc::clone(&stream_playing); let asio_streams = self.asio_streams.clone(); let mut current_buffer_size = buffer_size as i32; @@ -766,6 +785,15 @@ impl Device { driver.start().map_err(build_stream_err)?; + // Signal the event callback that the Stream handle is about to be returned. Any driver + // events that fired during `driver.start()` will unblock and be delivered to the caller + // after this point. + { + let (lock, cvar) = &*stream_ready; + *lock.lock().unwrap() = true; + cvar.notify_all(); + } + Ok(Stream { playing: stream_playing, driver, @@ -868,6 +896,7 @@ impl Device { error_callback: E, hardware_latency: Arc, is_input: bool, + stream_ready: Arc<(Mutex, Condvar)>, ) -> sys::DriverEventCallbackId where E: FnMut(Error) + Send + 'static, @@ -897,6 +926,13 @@ impl Device { ) } sys::AsioMessageSelectors::kAsioResetRequest => { + // Block until the Stream handle has been returned to the caller. + { + let (lock, cvar) = &*stream_ready; + let _guard = cvar + .wait_while(lock.lock().unwrap(), |ready| !*ready) + .unwrap(); + } error_callback_shared .lock() .unwrap_or_else(|e| e.into_inner())( @@ -908,6 +944,13 @@ impl Device { false } sys::AsioMessageSelectors::kAsioResyncRequest => { + // Block until the Stream handle has been returned to the caller. + { + let (lock, cvar) = &*stream_ready; + let _guard = cvar + .wait_while(lock.lock().unwrap(), |ready| !*ready) + .unwrap(); + } error_callback_shared .lock() .unwrap_or_else(|e| e.into_inner())( @@ -956,6 +999,13 @@ impl Device { } }; if should_notify { + // Block until the Stream handle has been returned to the caller. + { + let (lock, cvar) = &*stream_ready; + let _guard = cvar + .wait_while(lock.lock().unwrap(), |ready| !*ready) + .unwrap(); + } error_callback_shared .lock() .unwrap_or_else(|e| e.into_inner())( From 5e96c4bc62e68d97528b4ffef7ed679ad2476c9b Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Mon, 11 May 2026 22:42:24 +0200 Subject: [PATCH 05/22] chore: remove stale comments --- src/host/aaudio/mod.rs | 6 ++---- src/host/jack/stream.rs | 1 - 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/host/aaudio/mod.rs b/src/host/aaudio/mod.rs index 657884778..f8d5e13ae 100644 --- a/src/host/aaudio/mod.rs +++ b/src/host/aaudio/mod.rs @@ -327,8 +327,7 @@ where let error_callback: ErrorCallbackArc = Arc::new(Mutex::new(error_callback)); let error_callback_for_stream = error_callback.clone(); - // RT check: run once on the first callback invocation to avoid delivering RealtimeDenied - // before the Stream handle is returned to the caller. + // RT check: performed on the audio thread once per stream. #[cfg(feature = "realtime")] let mut rt_checked = false; #[cfg(feature = "realtime")] @@ -408,8 +407,7 @@ where let error_callback: ErrorCallbackArc = Arc::new(Mutex::new(error_callback)); let error_callback_for_stream = error_callback.clone(); - // RT check: run once on the first callback invocation to avoid delivering RealtimeDenied - // before the Stream handle is returned to the caller. + // RT check: performed on the audio thread once per stream. #[cfg(feature = "realtime")] let mut rt_checked = false; #[cfg(feature = "realtime")] diff --git a/src/host/jack/stream.rs b/src/host/jack/stream.rs index 76e82edd6..c334ddb75 100644 --- a/src/host/jack/stream.rs +++ b/src/host/jack/stream.rs @@ -12,7 +12,6 @@ use crate::{ }; pub struct Stream { - // TODO: It might be faster to send a message when playing/pausing than to check this every iteration playing: Arc, async_client: jack::AsyncClient, // Port names are stored in order to connect them to other ports in jack automatically From 6a79ffde3d5c9aef8400c1b627aa8e640acb6e79 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Mon, 11 May 2026 22:42:58 +0200 Subject: [PATCH 06/22] refactor: replace Barrier with Condvar to gate stream start --- src/host/alsa/mod.rs | 42 ++++++++++++++++++----------- src/host/pulseaudio/stream.rs | 51 ++++++++++++++++++++++++----------- src/host/wasapi/stream.rs | 42 ++++++++++++++++++----------- 3 files changed, 89 insertions(+), 46 deletions(-) diff --git a/src/host/alsa/mod.rs b/src/host/alsa/mod.rs index d53217b57..28e909222 100644 --- a/src/host/alsa/mod.rs +++ b/src/host/alsa/mod.rs @@ -9,7 +9,7 @@ use std::{ cmp, sync::{ atomic::{AtomicBool, Ordering}, - Arc, Mutex, + Arc, Condvar, Mutex, }, thread::{self, JoinHandle}, time::Duration, @@ -1265,16 +1265,20 @@ impl Stream { let rx_thread = rx.clone(); let stream = inner.clone(); - // The barrier prevents the worker from firing data callbacks before the caller has - // received the Stream handle. Without it, callbacks could arrive before the caller can - // pause, stop, or drop the stream. - let ready = std::sync::Arc::new(std::sync::Barrier::new(2)); - let ready_worker = ready.clone(); + // `stream_ready` is signalled just before the `Stream` is returned so the worker cannot + // fire any callbacks before the caller has the handle. + let stream_ready = Arc::new((Mutex::new(false), Condvar::new())); + let stream_ready_worker = stream_ready.clone(); let thread = thread::Builder::new() .name("cpal_alsa_in".to_owned()) .spawn(move || { - ready_worker.wait(); + { + let (lock, cvar) = &*stream_ready_worker; + let _guard = cvar + .wait_while(lock.lock().unwrap(), |ready| !*ready) + .unwrap(); + } input_stream_worker( rx_thread, &stream, @@ -1291,7 +1295,9 @@ impl Stream { _rx: rx, }; - ready.wait(); + let (lock, cvar) = &*stream_ready; + *lock.lock().unwrap() = true; + cvar.notify_one(); stream } @@ -1309,16 +1315,20 @@ impl Stream { let rx_thread = rx.clone(); let stream = inner.clone(); - // The barrier prevents the worker from firing data callbacks before the caller has - // received the Stream handle. Without it, callbacks could arrive before the caller can - // pause, stop, or drop the stream. - let ready = std::sync::Arc::new(std::sync::Barrier::new(2)); - let ready_worker = ready.clone(); + // `stream_ready` is signalled just before the `Stream` is returned so the worker cannot + // fire any callbacks before the caller has the handle. + let stream_ready = Arc::new((Mutex::new(false), Condvar::new())); + let stream_ready_worker = stream_ready.clone(); let thread = thread::Builder::new() .name("cpal_alsa_out".to_owned()) .spawn(move || { - ready_worker.wait(); + { + let (lock, cvar) = &*stream_ready_worker; + let _guard = cvar + .wait_while(lock.lock().unwrap(), |ready| !*ready) + .unwrap(); + } output_stream_worker( rx_thread, &stream, @@ -1336,7 +1346,9 @@ impl Stream { _rx: rx, }; - ready.wait(); + let (lock, cvar) = &*stream_ready; + *lock.lock().unwrap() = true; + cvar.notify_one(); stream } } diff --git a/src/host/pulseaudio/stream.rs b/src/host/pulseaudio/stream.rs index 7ce45492f..a195a40f8 100644 --- a/src/host/pulseaudio/stream.rs +++ b/src/host/pulseaudio/stream.rs @@ -1,7 +1,7 @@ use std::{ sync::{ - atomic::{self, AtomicBool, AtomicU64}, - Arc, Barrier, Condvar, Mutex, Ordering, + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, Condvar, Mutex, }, time::{Duration, Instant}, }; @@ -243,13 +243,18 @@ impl Stream { let error_callback_clone = error_callback.clone(); let cancel_driver = handle.cancel.clone(); - // The barrier prevents the worker and latency threads from firing callbacks before the - // caller has received the Stream handle. - let ready = Arc::new(Barrier::new(3)); + // `stream_ready` is signalled just before the `Stream` is returned so the driver and + // latency threads cannot fire any callbacks before the caller has the handle. + let stream_ready = Arc::new((Mutex::new(false), Condvar::new())); - let ready_worker = ready.clone(); + let stream_ready_driver = stream_ready.clone(); let driver_handle = std::thread::spawn(move || { - ready_worker.wait(); + { + let (lock, cvar) = &*stream_ready_driver; + let _guard = cvar + .wait_while(lock.lock().unwrap(), |ready| !*ready) + .unwrap(); + } if let Err(e) = block_on(stream_clone.play_all()) { // A server playback error is expected when the client // closes their stream. No need to report it back to @@ -266,9 +271,14 @@ impl Stream { let latency_clone = current_latency_micros.clone(); let poll_clone = last_poll_micros.clone(); - let ready_latency = ready.clone(); + let stream_ready_latency = stream_ready.clone(); let latency_handle = std::thread::spawn(move || { - ready_latency.wait(); + { + let (lock, cvar) = &*stream_ready_latency; + let _guard = cvar + .wait_while(lock.lock().unwrap(), |ready| !*ready) + .unwrap(); + } loop { if cancel_thread.load(Ordering::Relaxed) { break; @@ -304,7 +314,9 @@ impl Stream { } }); - ready.wait(); + let (lock, cvar) = &*stream_ready; + *lock.lock().unwrap() = true; + cvar.notify_all(); Ok(Self { inner: StreamInner::Playback(stream, start, handle), workers: vec![driver_handle, latency_handle], @@ -396,13 +408,18 @@ impl Stream { let latency_clone = current_latency_micros.clone(); let poll_clone = last_poll_micros.clone(); - // The barrier prevents the worker and latency threads from firing callbacks before the - // caller has received the Stream handle. - let ready = Arc::new(Barrier::new(2)); - let ready_latency = ready.clone(); + // `stream_ready` is signalled just before the `Stream` is returned so the latency thread + // cannot fire any callbacks before the caller has the handle. + let stream_ready = Arc::new((Mutex::new(false), Condvar::new())); + let stream_ready_latency = stream_ready.clone(); let latency_handle = std::thread::spawn(move || { - ready_latency.wait(); + { + let (lock, cvar) = &*stream_ready_latency; + let _guard = cvar + .wait_while(lock.lock().unwrap(), |ready| !*ready) + .unwrap(); + } loop { if cancel_thread.load(Ordering::Relaxed) { break; @@ -438,7 +455,9 @@ impl Stream { } }); - ready.wait(); + let (lock, cvar) = &*stream_ready; + *lock.lock().unwrap() = true; + cvar.notify_one(); Ok(Self { inner: StreamInner::Record(stream, start, handle), workers: vec![latency_handle], diff --git a/src/host/wasapi/stream.rs b/src/host/wasapi/stream.rs index 131aa4150..efede0a1a 100644 --- a/src/host/wasapi/stream.rs +++ b/src/host/wasapi/stream.rs @@ -5,7 +5,7 @@ use std::{ sync::{ atomic::{AtomicBool, Ordering}, mpsc::{channel, Receiver, SendError, Sender}, - Arc, + Arc, Condvar, Mutex, }, thread::{self, JoinHandle}, time::Duration, @@ -341,16 +341,20 @@ impl Stream { pending_scheduled_event, }; - // The barrier prevents the worker from firing data callbacks before the caller has - // received the Stream handle. Without it, callbacks could arrive before the caller can - // pause, stop, or drop the stream. - let ready = std::sync::Arc::new(std::sync::Barrier::new(2)); - let ready_worker = ready.clone(); + // `stream_ready` is signalled just before the `Stream` is returned so the worker cannot + // fire any callbacks before the caller has the handle. + let stream_ready = Arc::new((Mutex::new(false), Condvar::new())); + let stream_ready_worker = stream_ready.clone(); let thread = thread::Builder::new() .name("cpal_wasapi_in".to_owned()) .spawn(move || { - ready_worker.wait(); + { + let (lock, cvar) = &*stream_ready_worker; + let _guard = cvar + .wait_while(lock.lock().unwrap(), |ready| !*ready) + .unwrap(); + } run_input(run_context, &mut data_callback, &error_callback) }) .map_err(|e| { @@ -369,7 +373,9 @@ impl Stream { _default_device_monitor: default_device_monitor, }; - ready.wait(); + let (lock, cvar) = &*stream_ready; + *lock.lock().unwrap() = true; + cvar.notify_one(); Ok(stream) } @@ -412,16 +418,20 @@ impl Stream { pending_scheduled_event, }; - // The barrier prevents the worker from firing data callbacks before the caller has - // received the Stream handle. Without it, callbacks could arrive before the caller can - // pause, stop, or drop the stream. - let ready = std::sync::Arc::new(std::sync::Barrier::new(2)); - let ready_worker = ready.clone(); + // `stream_ready` is signalled just before the `Stream` is returned so the worker cannot + // fire any callbacks before the caller has the handle. + let stream_ready = Arc::new((Mutex::new(false), Condvar::new())); + let stream_ready_worker = stream_ready.clone(); let thread = thread::Builder::new() .name("cpal_wasapi_out".to_owned()) .spawn(move || { - ready_worker.wait(); + { + let (lock, cvar) = &*stream_ready_worker; + let _guard = cvar + .wait_while(lock.lock().unwrap(), |ready| !*ready) + .unwrap(); + } run_output(run_context, &mut data_callback, &error_callback) }) .map_err(|e| { @@ -440,7 +450,9 @@ impl Stream { _default_device_monitor: default_device_monitor, }; - ready.wait(); + let (lock, cvar) = &*stream_ready; + *lock.lock().unwrap() = true; + cvar.notify_one(); Ok(stream) } From 14fe3a44e0b5c1d889d863d2f8c96d7ee370a570 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Mon, 11 May 2026 23:25:06 +0200 Subject: [PATCH 07/22] fix(pipewire): address review points --- src/host/pipewire/device.rs | 51 +++++++++++++++++++++++++++---------- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/src/host/pipewire/device.rs b/src/host/pipewire/device.rs index e621ff31d..27dd7f961 100644 --- a/src/host/pipewire/device.rs +++ b/src/host/pipewire/device.rs @@ -3,7 +3,7 @@ use std::{ rc::Rc, sync::{ atomic::{AtomicU64, Ordering}, - Arc, + Arc, Condvar, Mutex, }, thread, time::Duration, @@ -320,6 +320,8 @@ impl DeviceTrait for Device { let (pw_play_tx, pw_play_rx) = pw::channel::channel::(); let (init_tx, init_rx) = std::sync::mpsc::channel::>(); + let ready = Arc::new((Mutex::new(false), Condvar::new())); + let ready_worker = ready.clone(); let device = self.clone(); let wait_timeout = timeout.unwrap_or(Duration::from_secs(2)); let initial_quantum = match config.buffer_size { @@ -349,7 +351,7 @@ impl DeviceTrait for Device { Ok(d) => d, Err(e) => { let _ = init_tx.send(Err(Error::with_message( - ErrorKind::DeviceNotAvailable, + ErrorKind::UnsupportedConfig, format!("PipeWire stream connection failed: {e}"), ))); return; @@ -419,13 +421,20 @@ impl DeviceTrait for Device { } }); - // All synchronous setup is complete. Signal the main thread. if init_tx.send(Ok(())).is_err() { - // Main thread timed out and dropped init_rx; exit cleanly. return; } - // RT priority promotion runs after the signal so it doesn't block stream creation. + // Wait until the caller has received the Stream handle before running the mainloop + // or invoking any callbacks. + { + let (lock, cvar) = &*ready_worker; + let mut started = lock.lock().unwrap(); + while !*started { + started = cvar.wait(started).unwrap(); + } + } + #[cfg(feature = "realtime")] if let Err(e) = audio_thread_priority::promote_current_thread_to_real_time( device.quantum as u32, @@ -456,12 +465,15 @@ impl DeviceTrait for Device { )) })?; - Ok(Stream { + let stream = Stream { handle: Some(handle), controller: pw_play_tx, last_quantum, start, - }) + }; + *ready.0.lock().unwrap() = true; + ready.1.notify_one(); + Ok(stream) } fn build_output_stream_raw( @@ -479,6 +491,8 @@ impl DeviceTrait for Device { let (pw_play_tx, pw_play_rx) = pw::channel::channel::(); let (init_tx, init_rx) = std::sync::mpsc::channel::>(); + let ready = Arc::new((Mutex::new(false), Condvar::new())); + let ready_worker = ready.clone(); let device = self.clone(); let wait_timeout = timeout.unwrap_or(Duration::from_secs(2)); let initial_quantum = match config.buffer_size { @@ -508,7 +522,7 @@ impl DeviceTrait for Device { Ok(d) => d, Err(e) => { let _ = init_tx.send(Err(Error::with_message( - ErrorKind::DeviceNotAvailable, + ErrorKind::UnsupportedConfig, format!("PipeWire stream connection failed: {e}"), ))); return; @@ -578,13 +592,20 @@ impl DeviceTrait for Device { } }); - // All synchronous setup is complete. Signal the main thread. if init_tx.send(Ok(())).is_err() { - // Main thread timed out and dropped init_rx; exit cleanly. return; } - // RT priority promotion runs after the signal so it doesn't block stream creation. + // Wait until the caller has received the Stream handle before running the mainloop + // or invoking any callbacks. + { + let (lock, cvar) = &*ready_worker; + let mut started = lock.lock().unwrap(); + while !*started { + started = cvar.wait(started).unwrap(); + } + } + #[cfg(feature = "realtime")] if let Err(e) = audio_thread_priority::promote_current_thread_to_real_time( device.quantum as u32, @@ -606,6 +627,7 @@ impl DeviceTrait for Device { format!("failed to create thread: {e}"), ) })?; + init_rx.recv_timeout(wait_timeout).unwrap_or_else(|_| { Err(Error::with_message( ErrorKind::DeviceNotAvailable, @@ -613,12 +635,15 @@ impl DeviceTrait for Device { )) })?; - Ok(Stream { + let stream = Stream { handle: Some(handle), controller: pw_play_tx, last_quantum, start, - }) + }; + *ready.0.lock().unwrap() = true; + ready.1.notify_one(); + Ok(stream) } } From c813acf256a58ff4598ccf9875cdca0152eb134a Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Mon, 11 May 2026 23:34:34 +0200 Subject: [PATCH 08/22] style: fix clippy lintss --- src/host/pipewire/device.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/host/pipewire/device.rs b/src/host/pipewire/device.rs index 27dd7f961..8bf40a58b 100644 --- a/src/host/pipewire/device.rs +++ b/src/host/pipewire/device.rs @@ -437,7 +437,7 @@ impl DeviceTrait for Device { #[cfg(feature = "realtime")] if let Err(e) = audio_thread_priority::promote_current_thread_to_real_time( - device.quantum as u32, + device.quantum, device.rate, ) { emit_error(&error_callback, Error::from(e)); @@ -608,7 +608,7 @@ impl DeviceTrait for Device { #[cfg(feature = "realtime")] if let Err(e) = audio_thread_priority::promote_current_thread_to_real_time( - device.quantum as u32, + device.quantum, device.rate, ) { emit_error(&error_callback, Error::from(e)); From b31f996e2ad0bf0fa0f2e6221360960b7e8e38da Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Tue, 12 May 2026 20:19:50 +0200 Subject: [PATCH 09/22] fix: address review points --- src/host/asio/stream.rs | 14 ++++++++++++-- src/host/coreaudio/ios/mod.rs | 8 ++------ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/host/asio/stream.rs b/src/host/asio/stream.rs index 82615e918..dbf03e775 100644 --- a/src/host/asio/stream.rs +++ b/src/host/asio/stream.rs @@ -405,7 +405,12 @@ impl Device { let driver = Arc::new(driver); let asio_streams = self.asio_streams.clone(); - driver.start().map_err(build_stream_err)?; + if let Err(e) = driver.start() { + // Remove the callbacks to avoid leaking them. + driver.remove_event_callback(driver_event_callback_id); + driver.remove_callback(callback_id); + return Err(build_stream_err(e)); + } // Signal the event callback that the Stream handle is about to be returned. Any driver // events that fired during `driver.start()` will unblock and be delivered to the caller @@ -783,7 +788,12 @@ impl Device { let driver = Arc::new(driver); let asio_streams = self.asio_streams.clone(); - driver.start().map_err(build_stream_err)?; + if let Err(e) = driver.start() { + // Remove the callbacks to avoid leaking them. + driver.remove_event_callback(driver_event_callback_id); + driver.remove_callback(callback_id); + return Err(build_stream_err(e)); + } // Signal the event callback that the Stream handle is about to be returned. Any driver // events that fired during `driver.start()` will unblock and be delivered to the caller diff --git a/src/host/coreaudio/ios/mod.rs b/src/host/coreaudio/ios/mod.rs index 219598645..3e0f5769c 100644 --- a/src/host/coreaudio/ios/mod.rs +++ b/src/host/coreaudio/ios/mod.rs @@ -188,11 +188,9 @@ impl DeviceTrait for Device { }, )?; - audio_unit.start()?; - Ok(Stream::new( StreamInner { - playing: true, + playing: false, audio_unit, }, session_manager, @@ -233,11 +231,9 @@ impl DeviceTrait for Device { }, )?; - audio_unit.start()?; - Ok(Stream::new( StreamInner { - playing: true, + playing: false, audio_unit, }, session_manager, From c0c177dd8bee7f6b3e8f05044aa385d06576a117 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Wed, 13 May 2026 19:57:40 +0200 Subject: [PATCH 10/22] refactor(asio): improve event handling and state management --- CHANGELOG.md | 5 +- src/host/asio/stream.rs | 199 ++++++++++++++++++++++------------------ 2 files changed, 115 insertions(+), 89 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a08e11a5d..654796a16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,7 +71,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **ASIO**: `Device::driver`, `asio_streams`, and `current_callback_flag` are no longer `pub`. - **ASIO**: Timestamps now include driver-reported hardware latency. - **ASIO**: Hardware latency is now re-queried when the driver reports `kAsioLatenciesChanged`. -- **ASIO**: Stream error callback now receives `ErrorKind::Xrun` on `kAsioResyncRequest`. +- **ASIO**: Stream error callback now receives `ErrorKind::StreamInvalidated` on + `kAsioResyncRequest`. + per the ASIO spec this event requires a full device reinitialisation, not merely an xrun. - **ASIO**: Stream error callback now receives `ErrorKind::StreamInvalidated` when the driver reports a sample rate change (`sampleRateDidChange`) of 1 Hz or more from the configured rate. - **AudioWorklet**: `BufferSize::Fixed` now sets `renderSizeHint` on the `AudioContext`. @@ -159,6 +161,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **ASIO**: Output buffers are now zero-filled before the callback runs. - **ASIO**: Fix `driver.sample_rate()` failures at stream creation being silently ignored. - **ASIO**: Fix callbacks firing before `build_*_stream` returns the `Stream` handle. +- **ASIO**: Fix overrun not being reported when the driver reports `kAsioOverload`. - **CoreAudio**: Fix default output streams silently stopping when the system default output device is unplugged; they now reroute automatically or report `ErrorKind::DeviceNotAvailable`. - **CoreAudio**: Fix undefined behaviour and silent failure in loopback device creation. diff --git a/src/host/asio/stream.rs b/src/host/asio/stream.rs index dbf03e775..5e3061396 100644 --- a/src/host/asio/stream.rs +++ b/src/host/asio/stream.rs @@ -3,8 +3,8 @@ extern crate num_traits; use std::{ sync::{ - atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}, - Arc, Condvar, Mutex, + atomic::{AtomicU32, AtomicU64, AtomicU8, Ordering}, + mpsc, Arc, Mutex, }, time::Duration, }; @@ -52,8 +52,34 @@ impl TimeBase { } } +/// Matches the `startTimer(500)` call JUCE uses for debouncing ASIO driver event notifications. +const ASIO_EVENT_DEBOUNCE: Duration = Duration::from_millis(500); + +#[repr(u8)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +enum StreamState { + #[default] + Idle = 0, + Paused = 1, + Playing = 2, +} + +impl StreamState { + fn load(atom: &AtomicU8) -> Self { + match atom.load(Ordering::Acquire) { + 1 => StreamState::Paused, + 2 => StreamState::Playing, + _ => StreamState::Idle, + } + } + + fn store(atom: &AtomicU8, state: StreamState) { + atom.store(state as u8, Ordering::Release); + } +} + pub struct Stream { - playing: Arc, + state: Arc, driver: Arc, asio_streams: Arc>, callback_id: sys::BufferCallbackId, @@ -75,12 +101,12 @@ impl Stream { } pub fn play(&self) -> Result<(), Error> { - self.playing.store(true, Ordering::Release); + StreamState::store(&self.state, StreamState::Playing); Ok(()) } pub fn pause(&self) -> Result<(), Error> { - self.playing.store(false, Ordering::Release); + StreamState::store(&self.state, StreamState::Paused); Ok(()) } @@ -157,21 +183,16 @@ impl Device { .unwrap_or(0), )); - // `stream_ready` is signalled just before `Ok(stream)` so that any driver events fired - // during `driver.start()` are not delivered until the caller holds the handle. - let stream_ready: Arc<(Mutex, Condvar)> = - Arc::new((Mutex::new(false), Condvar::new())); - let stream_playing = Arc::new(AtomicBool::new(false)); - + let state = Arc::new(AtomicU8::new(StreamState::Idle as u8)); let driver_event_callback_id = self.add_event_callback( &driver, error_callback, Arc::clone(&hardware_input_latency), true, - Arc::clone(&stream_ready), + Arc::clone(&state), ); - let playing = Arc::clone(&stream_playing); + let state_cb = Arc::clone(&state); let asio_streams = self.asio_streams.clone(); let mut current_buffer_size = buffer_size as i32; let mut last_buffer_index: i32 = -1; @@ -183,7 +204,7 @@ impl Device { // This is most performance critical part of the ASIO bindings. let callback_id = driver.add_callback(move |callback_info| unsafe { // If not playing, return early. - if !playing.load(Ordering::Acquire) { + if StreamState::load(&state_cb) != StreamState::Playing { return; } @@ -406,23 +427,16 @@ impl Device { let asio_streams = self.asio_streams.clone(); if let Err(e) = driver.start() { - // Remove the callbacks to avoid leaking them. + // `started` was never set, so the timer thread has received nothing and will + // exit cleanly once the event callback closure is dropped below. driver.remove_event_callback(driver_event_callback_id); driver.remove_callback(callback_id); return Err(build_stream_err(e)); } - // Signal the event callback that the Stream handle is about to be returned. Any driver - // events that fired during `driver.start()` will unblock and be delivered to the caller - // after this point. - { - let (lock, cvar) = &*stream_ready; - *lock.lock().unwrap() = true; - cvar.notify_all(); - } - + StreamState::store(&state, StreamState::Paused); Ok(Stream { - playing: stream_playing, + state, driver, asio_streams, callback_id, @@ -491,21 +505,16 @@ impl Device { .unwrap_or(0), )); - // `stream_ready` is signalled just before `Ok(stream)` so that any driver events fired - // during `driver.start()` are not delivered until the caller holds the handle. - let stream_ready: Arc<(Mutex, Condvar)> = - Arc::new((Mutex::new(false), Condvar::new())); - let stream_playing = Arc::new(AtomicBool::new(false)); - + let state = Arc::new(AtomicU8::new(StreamState::Idle as u8)); let driver_event_callback_id = self.add_event_callback( &driver, error_callback, Arc::clone(&hardware_output_latency), false, - Arc::clone(&stream_ready), + Arc::clone(&state), ); - let playing = Arc::clone(&stream_playing); + let state_cb = Arc::clone(&state); let asio_streams = self.asio_streams.clone(); let mut current_buffer_size = buffer_size as i32; let mut last_buffer_index: i32 = -1; @@ -515,7 +524,7 @@ impl Device { let callback_id = driver.add_callback(move |callback_info| unsafe { // If not playing, return early. - if !playing.load(Ordering::Acquire) { + if StreamState::load(&state_cb) != StreamState::Playing { return; } @@ -789,23 +798,16 @@ impl Device { let asio_streams = self.asio_streams.clone(); if let Err(e) = driver.start() { - // Remove the callbacks to avoid leaking them. + // `started` was never set, so the timer thread has received nothing and will + // exit cleanly once the event callback closure is dropped below. driver.remove_event_callback(driver_event_callback_id); driver.remove_callback(callback_id); return Err(build_stream_err(e)); } - // Signal the event callback that the Stream handle is about to be returned. Any driver - // events that fired during `driver.start()` will unblock and be delivered to the caller - // after this point. - { - let (lock, cvar) = &*stream_ready; - *lock.lock().unwrap() = true; - cvar.notify_all(); - } - + StreamState::store(&state, StreamState::Paused); Ok(Stream { - playing: stream_playing, + state, driver, asio_streams, callback_id, @@ -906,7 +908,7 @@ impl Device { error_callback: E, hardware_latency: Arc, is_input: bool, - stream_ready: Arc<(Mutex, Condvar)>, + state: Arc, ) -> sys::DriverEventCallbackId where E: FnMut(Error) + Send + 'static, @@ -922,6 +924,40 @@ impl Device { let driver_for_latency = driver.clone(); let asio_streams_for_event = self.asio_streams.clone(); + // Debounce timer: wait for ASIO_EVENT_DEBOUNCE of silence after the most recent event + // before delivering to the user. Exits when `timer_tx` is dropped, which happens when the + // event callback closure is removed during stream teardown. + let (timer_tx, timer_rx) = mpsc::channel::(); + let error_cb_for_timer = Arc::clone(&error_callback_shared); + let _ = std::thread::Builder::new() + .name("cpal-asio-event-timer".into()) + .spawn(move || { + let mut pending: Option = None; + loop { + // Use recv() when idle (no timeout needed) so we don't spin. + let result = if pending.is_some() { + timer_rx.recv_timeout(ASIO_EVENT_DEBOUNCE) + } else { + timer_rx + .recv() + .map_err(|_| mpsc::RecvTimeoutError::Disconnected) + }; + match result { + Ok(err) => { + // New event; restart the grace window. + pending = Some(err); + } + Err(mpsc::RecvTimeoutError::Timeout) => { + // Grace period elapsed with no new events: now deliver. + if let Some(err) = pending.take() { + error_cb_for_timer.lock().unwrap_or_else(|e| e.into_inner())(err); + } + } + Err(mpsc::RecvTimeoutError::Disconnected) => return, + } + } + }); + driver.add_event_callback(move |event| { match event { sys::AsioDriverEvent::Message { @@ -933,43 +969,39 @@ impl Device { matches!( sys::AsioMessageSelectors::from_i64(value as i64), Some(sys::AsioMessageSelectors::kAsioBufferSizeChange) + | Some(sys::AsioMessageSelectors::kAsioOverload) ) } sys::AsioMessageSelectors::kAsioResetRequest => { - // Block until the Stream handle has been returned to the caller. - { - let (lock, cvar) = &*stream_ready; - let _guard = cvar - .wait_while(lock.lock().unwrap(), |ready| !*ready) - .unwrap(); - } - error_callback_shared - .lock() - .unwrap_or_else(|e| e.into_inner())( - Error::with_message( + if StreamState::load(&state) != StreamState::Idle { + let _ = timer_tx.send(Error::with_message( ErrorKind::StreamInvalidated, "ASIO driver requested stream reset", - ), - ); - false + )); + } + true } sys::AsioMessageSelectors::kAsioResyncRequest => { - // Block until the Stream handle has been returned to the caller. - { - let (lock, cvar) = &*stream_ready; - let _guard = cvar - .wait_while(lock.lock().unwrap(), |ready| !*ready) - .unwrap(); + // Per the ASIO spec (and matching JUCE's behavior), kAsioResyncRequest + // means the driver needs a full stop/reinit/start. It is *not* a simple + // xrun notification. + if StreamState::load(&state) != StreamState::Idle { + let _ = timer_tx.send(Error::with_message( + ErrorKind::StreamInvalidated, + "ASIO driver requested stream resynchronization", + )); } - error_callback_shared - .lock() - .unwrap_or_else(|e| e.into_inner())( - Error::with_message( - ErrorKind::Xrun, - "ASIO driver requested resynchronization", - ), - ); - false + true + } + sys::AsioMessageSelectors::kAsioOverload => { + if StreamState::load(&state) != StreamState::Idle { + error_callback_shared + .lock() + .unwrap_or_else(|e| e.into_inner())( + Error::new(ErrorKind::Xrun) + ); + } + true } sys::AsioMessageSelectors::kAsioLatenciesChanged => { if let Ok(latencies) = driver_for_latency.latencies() { @@ -1009,21 +1041,12 @@ impl Device { } }; if should_notify { - // Block until the Stream handle has been returned to the caller. - { - let (lock, cvar) = &*stream_ready; - let _guard = cvar - .wait_while(lock.lock().unwrap(), |ready| !*ready) - .unwrap(); - } - error_callback_shared - .lock() - .unwrap_or_else(|e| e.into_inner())( - Error::with_message( + if StreamState::load(&state) != StreamState::Idle { + let _ = timer_tx.send(Error::with_message( ErrorKind::StreamInvalidated, format!("ASIO driver changed sample rate to {new_rate} Hz"), - ), - ); + )); + } } false } From 552f39fff7cdb6ab287a3974f19da756299ece92 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Wed, 13 May 2026 20:47:00 +0200 Subject: [PATCH 11/22] fix: address review points --- CHANGELOG.md | 1 - src/host/asio/stream.rs | 38 ++++++++++++++++++------------------- src/host/jack/stream.rs | 4 ++++ src/host/pipewire/device.rs | 22 +++++++++++++-------- 4 files changed, 37 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 654796a16..e3ecfef80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,7 +73,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **ASIO**: Hardware latency is now re-queried when the driver reports `kAsioLatenciesChanged`. - **ASIO**: Stream error callback now receives `ErrorKind::StreamInvalidated` on `kAsioResyncRequest`. - per the ASIO spec this event requires a full device reinitialisation, not merely an xrun. - **ASIO**: Stream error callback now receives `ErrorKind::StreamInvalidated` when the driver reports a sample rate change (`sampleRateDidChange`) of 1 Hz or more from the configured rate. - **AudioWorklet**: `BufferSize::Fixed` now sets `renderSizeHint` on the `AudioContext`. diff --git a/src/host/asio/stream.rs b/src/host/asio/stream.rs index 5e3061396..259ba3f97 100644 --- a/src/host/asio/stream.rs +++ b/src/host/asio/stream.rs @@ -190,7 +190,7 @@ impl Device { Arc::clone(&hardware_input_latency), true, Arc::clone(&state), - ); + )?; let state_cb = Arc::clone(&state); let asio_streams = self.asio_streams.clone(); @@ -427,8 +427,6 @@ impl Device { let asio_streams = self.asio_streams.clone(); if let Err(e) = driver.start() { - // `started` was never set, so the timer thread has received nothing and will - // exit cleanly once the event callback closure is dropped below. driver.remove_event_callback(driver_event_callback_id); driver.remove_callback(callback_id); return Err(build_stream_err(e)); @@ -512,7 +510,7 @@ impl Device { Arc::clone(&hardware_output_latency), false, Arc::clone(&state), - ); + )?; let state_cb = Arc::clone(&state); let asio_streams = self.asio_streams.clone(); @@ -798,8 +796,6 @@ impl Device { let asio_streams = self.asio_streams.clone(); if let Err(e) = driver.start() { - // `started` was never set, so the timer thread has received nothing and will - // exit cleanly once the event callback closure is dropped below. driver.remove_event_callback(driver_event_callback_id); driver.remove_callback(callback_id); return Err(build_stream_err(e)); @@ -909,7 +905,7 @@ impl Device { hardware_latency: Arc, is_input: bool, state: Arc, - ) -> sys::DriverEventCallbackId + ) -> Result where E: FnMut(Error) + Send + 'static, { @@ -929,7 +925,7 @@ impl Device { // event callback closure is removed during stream teardown. let (timer_tx, timer_rx) = mpsc::channel::(); let error_cb_for_timer = Arc::clone(&error_callback_shared); - let _ = std::thread::Builder::new() + std::thread::Builder::new() .name("cpal-asio-event-timer".into()) .spawn(move || { let mut pending: Option = None; @@ -956,9 +952,15 @@ impl Device { Err(mpsc::RecvTimeoutError::Disconnected) => return, } } - }); + }) + .map_err(|e| { + Error::with_message( + ErrorKind::ResourceExhausted, + format!("failed to spawn ASIO event timer thread: {e}"), + ) + })?; - driver.add_event_callback(move |event| { + Ok(driver.add_event_callback(move |event| { match event { sys::AsioDriverEvent::Message { selector: msg, @@ -994,7 +996,7 @@ impl Device { true } sys::AsioMessageSelectors::kAsioOverload => { - if StreamState::load(&state) != StreamState::Idle { + if StreamState::load(&state) == StreamState::Playing { error_callback_shared .lock() .unwrap_or_else(|e| e.into_inner())( @@ -1040,18 +1042,16 @@ impl Device { true } }; - if should_notify { - if StreamState::load(&state) != StreamState::Idle { - let _ = timer_tx.send(Error::with_message( - ErrorKind::StreamInvalidated, - format!("ASIO driver changed sample rate to {new_rate} Hz"), - )); - } + if should_notify && StreamState::load(&state) != StreamState::Idle { + let _ = timer_tx.send(Error::with_message( + ErrorKind::StreamInvalidated, + format!("ASIO driver changed sample rate to {new_rate} Hz"), + )); } false } } - }) + })) } } diff --git a/src/host/jack/stream.rs b/src/host/jack/stream.rs index c334ddb75..82047c84d 100644 --- a/src/host/jack/stream.rs +++ b/src/host/jack/stream.rs @@ -309,6 +309,10 @@ impl jack::ProcessHandler for LocalProcessHandler { process_scope: &jack::ProcessScope, ) -> jack::Control { if !self.playing.load(Ordering::Relaxed) { + // JACK does not zero-fill output port buffers before calling the process handler + for port in &mut self.out_ports { + port.as_mut_slice(process_scope).fill(f32::EQUILIBRIUM); + } return jack::Control::Continue; } diff --git a/src/host/pipewire/device.rs b/src/host/pipewire/device.rs index 8bf40a58b..230e894c5 100644 --- a/src/host/pipewire/device.rs +++ b/src/host/pipewire/device.rs @@ -426,12 +426,15 @@ impl DeviceTrait for Device { } // Wait until the caller has received the Stream handle before running the mainloop - // or invoking any callbacks. + // or invoking any callbacks. Use a timeout so that if the caller's recv_timeout + // races and returns early (without signalling us), this thread does not block forever. { let (lock, cvar) = &*ready_worker; - let mut started = lock.lock().unwrap(); - while !*started { - started = cvar.wait(started).unwrap(); + let (started, _) = cvar + .wait_timeout_while(lock.lock().unwrap(), wait_timeout, |s| !*s) + .unwrap(); + if !*started { + return; } } @@ -597,12 +600,15 @@ impl DeviceTrait for Device { } // Wait until the caller has received the Stream handle before running the mainloop - // or invoking any callbacks. + // or invoking any callbacks. Use a timeout so that if the caller's recv_timeout + // races and returns early, this thread does not block forever. { let (lock, cvar) = &*ready_worker; - let mut started = lock.lock().unwrap(); - while !*started { - started = cvar.wait(started).unwrap(); + let (started, _) = cvar + .wait_timeout_while(lock.lock().unwrap(), wait_timeout, |s| !*s) + .unwrap(); + if !*started { + return; } } From c7e22746994fb777940d69bee5151942a010e6f7 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Wed, 13 May 2026 21:56:06 +0200 Subject: [PATCH 12/22] refactor: address review points --- src/host/asio/stream.rs | 2 ++ src/host/pulseaudio/mod.rs | 12 ++++++--- src/host/pulseaudio/stream.rs | 47 +++++++++++++++++------------------ 3 files changed, 33 insertions(+), 28 deletions(-) diff --git a/src/host/asio/stream.rs b/src/host/asio/stream.rs index 259ba3f97..3ff7b833d 100644 --- a/src/host/asio/stream.rs +++ b/src/host/asio/stream.rs @@ -975,6 +975,8 @@ impl Device { ) } sys::AsioMessageSelectors::kAsioResetRequest => { + // Guard on Idle: some USB ASIO drivers (ASIO4ALL, Focusrite, etc.) fire + // spurious reset/resync requests during as driver.start(). if StreamState::load(&state) != StreamState::Idle { let _ = timer_tx.send(Error::with_message( ErrorKind::StreamInvalidated, diff --git a/src/host/pulseaudio/mod.rs b/src/host/pulseaudio/mod.rs index 3caa9ebea..faf3ceaf4 100644 --- a/src/host/pulseaudio/mod.rs +++ b/src/host/pulseaudio/mod.rs @@ -338,7 +338,7 @@ impl DeviceTrait for Device { }; let client = client.clone(); - if let Some(dur) = timeout { + let stream = if let Some(dur) = timeout { // Run stream creation on a thread so we can bound the wait. If the PulseAudio server // is hung, `create_record_stream` would block forever. let (tx, rx) = std::sync::mpsc::channel(); @@ -360,7 +360,9 @@ impl DeviceTrait for Device { } } else { stream::Stream::new_record(client, params, data_callback, error_callback) - } + }?; + stream.wake_workers(); + Ok(stream) } fn build_output_stream_raw( @@ -411,7 +413,7 @@ impl DeviceTrait for Device { }; let client = client.clone(); - if let Some(dur) = timeout { + let stream = if let Some(dur) = timeout { // Run stream creation on a thread so we can bound the wait. If the PulseAudio server // is hung, `create_playback_stream` would block forever. let (tx, rx) = std::sync::mpsc::channel(); @@ -433,7 +435,9 @@ impl DeviceTrait for Device { } } else { stream::Stream::new_playback(client, params, data_callback, error_callback) - } + }?; + stream.wake_workers(); + Ok(stream) } fn description(&self) -> Result { diff --git a/src/host/pulseaudio/stream.rs b/src/host/pulseaudio/stream.rs index a195a40f8..15508f409 100644 --- a/src/host/pulseaudio/stream.rs +++ b/src/host/pulseaudio/stream.rs @@ -57,6 +57,7 @@ enum StreamInner { pub struct Stream { inner: StreamInner, workers: Vec>, + ready: Arc, } impl Drop for Stream { @@ -74,6 +75,10 @@ impl Drop for Stream { handle.cancel(); } } + + // Unpark the threads in case they're sleeping. + self.wake_workers(); + for handle in self.workers.drain(..) { // Prevent self-join: a worker thread may surface an error // through the user's error_callback, and that callback may @@ -245,15 +250,12 @@ impl Stream { // `stream_ready` is signalled just before the `Stream` is returned so the driver and // latency threads cannot fire any callbacks before the caller has the handle. - let stream_ready = Arc::new((Mutex::new(false), Condvar::new())); - + let stream_ready = Arc::new(AtomicBool::new(false)); let stream_ready_driver = stream_ready.clone(); + let driver_handle = std::thread::spawn(move || { - { - let (lock, cvar) = &*stream_ready_driver; - let _guard = cvar - .wait_while(lock.lock().unwrap(), |ready| !*ready) - .unwrap(); + while !stream_ready_driver.load(Ordering::Acquire) { + std::thread::park(); } if let Err(e) = block_on(stream_clone.play_all()) { // A server playback error is expected when the client @@ -273,11 +275,8 @@ impl Stream { let stream_ready_latency = stream_ready.clone(); let latency_handle = std::thread::spawn(move || { - { - let (lock, cvar) = &*stream_ready_latency; - let _guard = cvar - .wait_while(lock.lock().unwrap(), |ready| !*ready) - .unwrap(); + while !stream_ready_latency.load(Ordering::Acquire) { + std::thread::park(); } loop { if cancel_thread.load(Ordering::Relaxed) { @@ -314,12 +313,10 @@ impl Stream { } }); - let (lock, cvar) = &*stream_ready; - *lock.lock().unwrap() = true; - cvar.notify_all(); Ok(Self { inner: StreamInner::Playback(stream, start, handle), workers: vec![driver_handle, latency_handle], + ready: stream_ready, }) } @@ -410,15 +407,12 @@ impl Stream { // `stream_ready` is signalled just before the `Stream` is returned so the latency thread // cannot fire any callbacks before the caller has the handle. - let stream_ready = Arc::new((Mutex::new(false), Condvar::new())); + let stream_ready = Arc::new(AtomicBool::new(false)); let stream_ready_latency = stream_ready.clone(); let latency_handle = std::thread::spawn(move || { - { - let (lock, cvar) = &*stream_ready_latency; - let _guard = cvar - .wait_while(lock.lock().unwrap(), |ready| !*ready) - .unwrap(); + while !stream_ready_latency.load(Ordering::Acquire) { + std::thread::park(); } loop { if cancel_thread.load(Ordering::Relaxed) { @@ -455,14 +449,19 @@ impl Stream { } }); - let (lock, cvar) = &*stream_ready; - *lock.lock().unwrap() = true; - cvar.notify_one(); Ok(Self { inner: StreamInner::Record(stream, start, handle), workers: vec![latency_handle], + ready: stream_ready, }) } + + pub(crate) fn wake_workers(&self) { + self.ready.store(true, Ordering::Release); + for handle in &self.workers { + handle.thread().unpark(); + } + } } fn store_latency( From ede286cce9b884e90463ea397838f69e6ac897c9 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Thu, 14 May 2026 11:01:37 +0200 Subject: [PATCH 13/22] refactor: signal stream readiness with AtomicBool/park --- src/host/alsa/mod.rs | 57 +++++++++++++++++---------------- src/host/asio/stream.rs | 2 +- src/host/pipewire/device.rs | 60 ++++++++++------------------------- src/host/pipewire/stream.rs | 40 ++++++++++++++++++++--- src/host/pulseaudio/mod.rs | 4 +-- src/host/pulseaudio/stream.rs | 4 +-- src/host/wasapi/device.rs | 8 +++-- src/host/wasapi/stream.rs | 46 +++++++++++++++------------ 8 files changed, 117 insertions(+), 104 deletions(-) diff --git a/src/host/alsa/mod.rs b/src/host/alsa/mod.rs index 28e909222..aafc1b2d3 100644 --- a/src/host/alsa/mod.rs +++ b/src/host/alsa/mod.rs @@ -9,7 +9,7 @@ use std::{ cmp, sync::{ atomic::{AtomicBool, Ordering}, - Arc, Condvar, Mutex, + Arc, Mutex, }, thread::{self, JoinHandle}, time::Duration, @@ -236,6 +236,7 @@ impl DeviceTrait for Device { error_callback, timeout, ); + stream.signal_ready(); Ok(stream) } @@ -259,6 +260,7 @@ impl DeviceTrait for Device { error_callback, timeout, ); + stream.signal_ready(); Ok(stream) } } @@ -773,6 +775,10 @@ pub struct Stream { /// Keeps the read end of the self-pipe alive for the lifetime of the Stream, so that /// `trigger.wakeup()` never writes to a closed pipe, even if the worker exited early. _rx: Arc, + + /// Gate that prevents the worker thread from firing callbacks until the caller has received + /// the `Stream` handle. + stream_ready: Arc, } // Compile-time assertion that Stream is Send and Sync @@ -1251,6 +1257,14 @@ fn htstamp_elapsed(status: &alsa::pcm::Status, origin: libc::timespec) -> Stream } impl Stream { + /// Unblocks the worker thread so it can begin processing audio callbacks. + pub(crate) fn signal_ready(&self) { + self.stream_ready.store(true, Ordering::Release); + if let Some(handle) = &self.thread { + handle.thread().unpark(); + } + } + fn new_input( inner: Arc, mut data_callback: D, @@ -1267,17 +1281,14 @@ impl Stream { // `stream_ready` is signalled just before the `Stream` is returned so the worker cannot // fire any callbacks before the caller has the handle. - let stream_ready = Arc::new((Mutex::new(false), Condvar::new())); + let stream_ready = Arc::new(AtomicBool::new(false)); let stream_ready_worker = stream_ready.clone(); let thread = thread::Builder::new() .name("cpal_alsa_in".to_owned()) .spawn(move || { - { - let (lock, cvar) = &*stream_ready_worker; - let _guard = cvar - .wait_while(lock.lock().unwrap(), |ready| !*ready) - .unwrap(); + while !stream_ready_worker.load(Ordering::Acquire) { + std::thread::park(); } input_stream_worker( rx_thread, @@ -1288,17 +1299,13 @@ impl Stream { ); }) .unwrap(); - let stream = Self { + Self { thread: Some(thread), inner, trigger: tx, _rx: rx, - }; - - let (lock, cvar) = &*stream_ready; - *lock.lock().unwrap() = true; - cvar.notify_one(); - stream + stream_ready, + } } fn new_output( @@ -1317,17 +1324,14 @@ impl Stream { // `stream_ready` is signalled just before the `Stream` is returned so the worker cannot // fire any callbacks before the caller has the handle. - let stream_ready = Arc::new((Mutex::new(false), Condvar::new())); + let stream_ready = Arc::new(AtomicBool::new(false)); let stream_ready_worker = stream_ready.clone(); let thread = thread::Builder::new() .name("cpal_alsa_out".to_owned()) .spawn(move || { - { - let (lock, cvar) = &*stream_ready_worker; - let _guard = cvar - .wait_while(lock.lock().unwrap(), |ready| !*ready) - .unwrap(); + while !stream_ready_worker.load(Ordering::Acquire) { + std::thread::park(); } output_stream_worker( rx_thread, @@ -1339,22 +1343,21 @@ impl Stream { }) .unwrap(); - let stream = Self { + Self { thread: Some(thread), inner, trigger: tx, _rx: rx, - }; - - let (lock, cvar) = &*stream_ready; - *lock.lock().unwrap() = true; - cvar.notify_one(); - stream + stream_ready, + } } } impl Drop for Stream { fn drop(&mut self) { + // Unblock the worker in case the stream is dropped before signal_ready() was + // called. Idempotent: no effect if the worker is already running. + self.signal_ready(); self.inner.dropping.store(true, Ordering::Release); self.trigger.wakeup(); if let Some(handle) = self.thread.take() { diff --git a/src/host/asio/stream.rs b/src/host/asio/stream.rs index 3ff7b833d..9e9ae93e8 100644 --- a/src/host/asio/stream.rs +++ b/src/host/asio/stream.rs @@ -976,7 +976,7 @@ impl Device { } sys::AsioMessageSelectors::kAsioResetRequest => { // Guard on Idle: some USB ASIO drivers (ASIO4ALL, Focusrite, etc.) fire - // spurious reset/resync requests during as driver.start(). + // spurious reset/resync requests during driver.start(). if StreamState::load(&state) != StreamState::Idle { let _ = timer_tx.send(Error::with_message( ErrorKind::StreamInvalidated, diff --git a/src/host/pipewire/device.rs b/src/host/pipewire/device.rs index 230e894c5..baa9dce5f 100644 --- a/src/host/pipewire/device.rs +++ b/src/host/pipewire/device.rs @@ -2,8 +2,8 @@ use std::{ cell::RefCell, rc::Rc, sync::{ - atomic::{AtomicU64, Ordering}, - Arc, Condvar, Mutex, + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, }, thread, time::Duration, @@ -320,8 +320,8 @@ impl DeviceTrait for Device { let (pw_play_tx, pw_play_rx) = pw::channel::channel::(); let (init_tx, init_rx) = std::sync::mpsc::channel::>(); - let ready = Arc::new((Mutex::new(false), Condvar::new())); - let ready_worker = ready.clone(); + let stream_ready = Arc::new(AtomicBool::new(false)); + let stream_ready_worker = stream_ready.clone(); let device = self.clone(); let wait_timeout = timeout.unwrap_or(Duration::from_secs(2)); let initial_quantum = match config.buffer_size { @@ -425,17 +425,9 @@ impl DeviceTrait for Device { return; } - // Wait until the caller has received the Stream handle before running the mainloop - // or invoking any callbacks. Use a timeout so that if the caller's recv_timeout - // races and returns early (without signalling us), this thread does not block forever. - { - let (lock, cvar) = &*ready_worker; - let (started, _) = cvar - .wait_timeout_while(lock.lock().unwrap(), wait_timeout, |s| !*s) - .unwrap(); - if !*started { - return; - } + // Park until the builder calls signal_ready() after constructing the Stream. + while !stream_ready_worker.load(Ordering::Acquire) { + std::thread::park(); } #[cfg(feature = "realtime")] @@ -468,14 +460,8 @@ impl DeviceTrait for Device { )) })?; - let stream = Stream { - handle: Some(handle), - controller: pw_play_tx, - last_quantum, - start, - }; - *ready.0.lock().unwrap() = true; - ready.1.notify_one(); + let stream = Stream::new(handle, pw_play_tx, last_quantum, start, stream_ready); + stream.signal_ready(); Ok(stream) } @@ -494,8 +480,8 @@ impl DeviceTrait for Device { let (pw_play_tx, pw_play_rx) = pw::channel::channel::(); let (init_tx, init_rx) = std::sync::mpsc::channel::>(); - let ready = Arc::new((Mutex::new(false), Condvar::new())); - let ready_worker = ready.clone(); + let stream_ready = Arc::new(AtomicBool::new(false)); + let stream_ready_worker = stream_ready.clone(); let device = self.clone(); let wait_timeout = timeout.unwrap_or(Duration::from_secs(2)); let initial_quantum = match config.buffer_size { @@ -599,17 +585,9 @@ impl DeviceTrait for Device { return; } - // Wait until the caller has received the Stream handle before running the mainloop - // or invoking any callbacks. Use a timeout so that if the caller's recv_timeout - // races and returns early, this thread does not block forever. - { - let (lock, cvar) = &*ready_worker; - let (started, _) = cvar - .wait_timeout_while(lock.lock().unwrap(), wait_timeout, |s| !*s) - .unwrap(); - if !*started { - return; - } + // Park until the builder calls signal_ready() after constructing the Stream. + while !stream_ready_worker.load(Ordering::Acquire) { + std::thread::park(); } #[cfg(feature = "realtime")] @@ -641,14 +619,8 @@ impl DeviceTrait for Device { )) })?; - let stream = Stream { - handle: Some(handle), - controller: pw_play_tx, - last_quantum, - start, - }; - *ready.0.lock().unwrap() = true; - ready.1.notify_one(); + let stream = Stream::new(handle, pw_play_tx, last_quantum, start, stream_ready); + stream.signal_ready(); Ok(stream) } } diff --git a/src/host/pipewire/stream.rs b/src/host/pipewire/stream.rs index 4034024ae..1aefdad49 100644 --- a/src/host/pipewire/stream.rs +++ b/src/host/pipewire/stream.rs @@ -74,15 +74,45 @@ pub enum StreamCommand { Stop, } -pub struct Stream { - pub(crate) handle: Option>, - pub(crate) controller: pw::channel::Sender, - pub(crate) last_quantum: Arc, - pub(crate) start: Instant, +pub(super) struct Stream { + handle: Option>, + controller: pw::channel::Sender, + last_quantum: Arc, + start: Instant, + stream_ready: Arc, +} + +impl Stream { + pub(super) fn new( + handle: JoinHandle<()>, + controller: pw::channel::Sender, + last_quantum: Arc, + start: Instant, + stream_ready: Arc, + ) -> Self { + Self { + handle: Some(handle), + controller, + last_quantum, + start, + stream_ready, + } + } + + /// Unblocks the worker thread so it can begin processing audio callbacks. + /// Idempotent; does nothing if the worker is already running. + pub(super) fn signal_ready(&self) { + self.stream_ready.store(true, Ordering::Release); + if let Some(handle) = &self.handle { + handle.thread().unpark(); + } + } } impl Drop for Stream { fn drop(&mut self) { + // Unblock the worker in case the stream is dropped before signal_ready() was called. + self.signal_ready(); let _ = self.controller.send(StreamCommand::Stop); if let Some(handle) = self.handle.take() { // Prevent self-join: Stop was sent; the handle detaches and the thread exits after diff --git a/src/host/pulseaudio/mod.rs b/src/host/pulseaudio/mod.rs index faf3ceaf4..a2a7ca282 100644 --- a/src/host/pulseaudio/mod.rs +++ b/src/host/pulseaudio/mod.rs @@ -361,7 +361,7 @@ impl DeviceTrait for Device { } else { stream::Stream::new_record(client, params, data_callback, error_callback) }?; - stream.wake_workers(); + stream.signal_ready(); Ok(stream) } @@ -436,7 +436,7 @@ impl DeviceTrait for Device { } else { stream::Stream::new_playback(client, params, data_callback, error_callback) }?; - stream.wake_workers(); + stream.signal_ready(); Ok(stream) } diff --git a/src/host/pulseaudio/stream.rs b/src/host/pulseaudio/stream.rs index 15508f409..ddd2d89c0 100644 --- a/src/host/pulseaudio/stream.rs +++ b/src/host/pulseaudio/stream.rs @@ -77,7 +77,7 @@ impl Drop for Stream { } // Unpark the threads in case they're sleeping. - self.wake_workers(); + self.signal_ready(); for handle in self.workers.drain(..) { // Prevent self-join: a worker thread may surface an error @@ -456,7 +456,7 @@ impl Stream { }) } - pub(crate) fn wake_workers(&self) { + pub(crate) fn signal_ready(&self) { self.ready.store(true, Ordering::Release); for handle in &self.workers { handle.thread().unpark(); diff --git a/src/host/wasapi/device.rs b/src/host/wasapi/device.rs index 563dd9249..9e0ea918e 100644 --- a/src/host/wasapi/device.rs +++ b/src/host/wasapi/device.rs @@ -142,7 +142,9 @@ impl DeviceTrait for Device { let stream_inner = self.build_input_stream_raw_inner(config, sample_format, timeout)?; let error_callback: ErrorCallbackArc = Arc::new(Mutex::new(error_callback)); let monitor = self.default_device_monitor()?; - Stream::new_input(stream_inner, data_callback, error_callback, monitor) + let stream = Stream::new_input(stream_inner, data_callback, error_callback, monitor)?; + stream.signal_ready(); + Ok(stream) } fn build_output_stream_raw( @@ -160,7 +162,9 @@ impl DeviceTrait for Device { let stream_inner = self.build_output_stream_raw_inner(config, sample_format, timeout)?; let error_callback: ErrorCallbackArc = Arc::new(Mutex::new(error_callback)); let monitor = self.default_device_monitor()?; - Stream::new_output(stream_inner, data_callback, error_callback, monitor) + let stream = Stream::new_output(stream_inner, data_callback, error_callback, monitor)?; + stream.signal_ready(); + Ok(stream) } } diff --git a/src/host/wasapi/stream.rs b/src/host/wasapi/stream.rs index efede0a1a..eaefb1a39 100644 --- a/src/host/wasapi/stream.rs +++ b/src/host/wasapi/stream.rs @@ -5,7 +5,7 @@ use std::{ sync::{ atomic::{AtomicBool, Ordering}, mpsc::{channel, Receiver, SendError, Sender}, - Arc, Condvar, Mutex, + Arc, }, thread::{self, JoinHandle}, time::Duration, @@ -212,6 +212,9 @@ pub struct Stream { // default changes. Dropped after the run thread joins, ensuring the HANDLE is not // waited on when it is closed. _default_device_monitor: Option, + + // Gate that ensures no callbacks fire before the caller receives the `Stream` handle. + stream_ready: Arc, } // SAFETY: Windows Event HANDLEs are safe to send between threads - they are designed for @@ -219,6 +222,7 @@ pub struct Stream { // - JoinHandle<()> is Send // - Sender is Send // - Foundation::HANDLE is Send (Windows synchronization primitive) +// - Arc is Send // See: https://learn.microsoft.com/en-us/windows/win32/api/synchapi/nf-synchapi-createeventa unsafe impl Send for Stream {} @@ -228,6 +232,7 @@ unsafe impl Send for Stream {} // - JoinHandle<()> is Sync // - Sender is Sync (uses internal synchronization) // - Foundation::HANDLE for event objects supports concurrent access +// - Arc is Sync // The audio thread owns all COM objects, so no cross-thread COM access occurs. unsafe impl Sync for Stream {} @@ -343,17 +348,14 @@ impl Stream { // `stream_ready` is signalled just before the `Stream` is returned so the worker cannot // fire any callbacks before the caller has the handle. - let stream_ready = Arc::new((Mutex::new(false), Condvar::new())); + let stream_ready = Arc::new(AtomicBool::new(false)); let stream_ready_worker = stream_ready.clone(); let thread = thread::Builder::new() .name("cpal_wasapi_in".to_owned()) .spawn(move || { - { - let (lock, cvar) = &*stream_ready_worker; - let _guard = cvar - .wait_while(lock.lock().unwrap(), |ready| !*ready) - .unwrap(); + while !stream_ready_worker.load(Ordering::Acquire) { + std::thread::park(); } run_input(run_context, &mut data_callback, &error_callback) }) @@ -371,11 +373,8 @@ impl Stream { period_frames, qpc_frequency: qpc_frequency as u64, _default_device_monitor: default_device_monitor, + stream_ready, }; - - let (lock, cvar) = &*stream_ready; - *lock.lock().unwrap() = true; - cvar.notify_one(); Ok(stream) } @@ -420,17 +419,14 @@ impl Stream { // `stream_ready` is signalled just before the `Stream` is returned so the worker cannot // fire any callbacks before the caller has the handle. - let stream_ready = Arc::new((Mutex::new(false), Condvar::new())); + let stream_ready = Arc::new(AtomicBool::new(false)); let stream_ready_worker = stream_ready.clone(); let thread = thread::Builder::new() .name("cpal_wasapi_out".to_owned()) .spawn(move || { - { - let (lock, cvar) = &*stream_ready_worker; - let _guard = cvar - .wait_while(lock.lock().unwrap(), |ready| !*ready) - .unwrap(); + while !stream_ready_worker.load(Ordering::Acquire) { + std::thread::park(); } run_output(run_context, &mut data_callback, &error_callback) }) @@ -448,14 +444,19 @@ impl Stream { period_frames, qpc_frequency: qpc_frequency as u64, _default_device_monitor: default_device_monitor, + stream_ready, }; - - let (lock, cvar) = &*stream_ready; - *lock.lock().unwrap() = true; - cvar.notify_one(); Ok(stream) } + /// Unblocks the worker thread so it can begin processing audio callbacks. + pub(crate) fn signal_ready(&self) { + self.stream_ready.store(true, Ordering::Release); + if let Some(handle) = &self.thread { + handle.thread().unpark(); + } + } + fn push_command(&self, command: Command) -> Result<(), SendError> { self.commands.send(command)?; unsafe { @@ -467,6 +468,9 @@ impl Stream { impl Drop for Stream { fn drop(&mut self) { + // Unblock the worker in case the stream is dropped before signal_ready() was called. + self.signal_ready(); + let _ = self.push_command(Command::Terminate); if let Some(handle) = self.thread.take() { // Prevent self-join: Terminate was sent; the thread exits after the current callback From 63bf488fa1b7241d8405469755fe17039752ca07 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Thu, 14 May 2026 11:04:40 +0200 Subject: [PATCH 14/22] fix: incorrect visibility --- src/host/pipewire/stream.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/host/pipewire/stream.rs b/src/host/pipewire/stream.rs index 1aefdad49..75110d751 100644 --- a/src/host/pipewire/stream.rs +++ b/src/host/pipewire/stream.rs @@ -74,7 +74,7 @@ pub enum StreamCommand { Stop, } -pub(super) struct Stream { +pub struct Stream { handle: Option>, controller: pw::channel::Sender, last_quantum: Arc, @@ -83,7 +83,7 @@ pub(super) struct Stream { } impl Stream { - pub(super) fn new( + pub fn new( handle: JoinHandle<()>, controller: pw::channel::Sender, last_quantum: Arc, @@ -101,7 +101,7 @@ impl Stream { /// Unblocks the worker thread so it can begin processing audio callbacks. /// Idempotent; does nothing if the worker is already running. - pub(super) fn signal_ready(&self) { + pub fn signal_ready(&self) { self.stream_ready.store(true, Ordering::Release); if let Some(handle) = &self.handle { handle.thread().unpark(); From b802f6df4a1aeff985ffcc891c843beae739013f Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Thu, 14 May 2026 13:49:58 +0200 Subject: [PATCH 15/22] refactor(jack): use AtomicU8 for stream state --- src/host/jack/stream.rs | 109 +++++++++++++++++++++++++--------------- 1 file changed, 68 insertions(+), 41 deletions(-) diff --git a/src/host/jack/stream.rs b/src/host/jack/stream.rs index 82047c84d..b900e1fa2 100644 --- a/src/host/jack/stream.rs +++ b/src/host/jack/stream.rs @@ -1,5 +1,5 @@ use std::sync::{ - atomic::{AtomicBool, Ordering}, + atomic::{AtomicU8, Ordering}, Arc, Mutex, }; @@ -11,8 +11,31 @@ use crate::{ OutputCallbackInfo, OutputStreamTimestamp, ResultExt, Sample, SampleRate, StreamInstant, }; +#[repr(u8)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +enum StreamState { + #[default] + Initializing = 0, + Paused = 1, + Playing = 2, +} + +impl StreamState { + fn load(atom: &AtomicU8, order: Ordering) -> Self { + match atom.load(order) { + 1 => Self::Paused, + 2 => Self::Playing, + _ => Self::Initializing, + } + } + + fn store(self, atom: &AtomicU8, order: Ordering) { + atom.store(self as u8, order); + } +} + pub struct Stream { - playing: Arc, + state: Arc, async_client: jack::AsyncClient, // Port names are stored in order to connect them to other ports in jack automatically input_port_names: Vec, @@ -46,7 +69,7 @@ impl Stream { ports.push(port); } - let playing = Arc::new(AtomicBool::new(false)); + let state = Arc::new(AtomicU8::new(StreamState::Initializing as u8)); let error_callback_ptr: ErrorCallbackArc = Arc::new(Mutex::new(error_callback)); let input_process_handler = LocalProcessHandler::new( @@ -56,19 +79,20 @@ impl Stream { client.buffer_size() as usize, Some(Box::new(data_callback)), None, - playing.clone(), + state.clone(), #[cfg(feature = "realtime")] error_callback_ptr.clone(), ); - let notification_handler = JackNotificationHandler::new(error_callback_ptr); + let notification_handler = JackNotificationHandler::new(error_callback_ptr, state.clone()); let async_client = client .activate_async(notification_handler, input_process_handler) .context("failed to activate JACK client")?; + StreamState::Paused.store(&state, Ordering::Release); Ok(Self { - playing, + state, async_client, input_port_names: port_names, output_port_names: vec![], @@ -97,7 +121,7 @@ impl Stream { ports.push(port); } - let playing = Arc::new(AtomicBool::new(false)); + let state = Arc::new(AtomicU8::new(StreamState::Initializing as u8)); let error_callback_ptr: ErrorCallbackArc = Arc::new(Mutex::new(error_callback)); let output_process_handler = LocalProcessHandler::new( @@ -107,19 +131,20 @@ impl Stream { client.buffer_size() as usize, None, Some(Box::new(data_callback)), - playing.clone(), + state.clone(), #[cfg(feature = "realtime")] error_callback_ptr.clone(), ); - let notification_handler = JackNotificationHandler::new(error_callback_ptr); + let notification_handler = JackNotificationHandler::new(error_callback_ptr, state.clone()); let async_client = client .activate_async(notification_handler, output_process_handler) .context("failed to activate JACK client")?; + StreamState::Paused.store(&state, Ordering::Release); Ok(Self { - playing, + state, async_client, input_port_names: vec![], output_port_names: port_names, @@ -221,12 +246,12 @@ impl Stream { impl StreamTrait for Stream { fn play(&self) -> Result<(), Error> { - self.playing.store(true, Ordering::Relaxed); + StreamState::Playing.store(&self.state, Ordering::Relaxed); Ok(()) } fn pause(&self) -> Result<(), Error> { - self.playing.store(false, Ordering::Relaxed); + StreamState::Paused.store(&self.state, Ordering::Relaxed); Ok(()) } @@ -255,7 +280,7 @@ struct LocalProcessHandler { // JACK audio samples are 32-bit float (unless you do some custom dark magic) temp_input_buffer: Vec, temp_output_buffer: Vec, - playing: Arc, + state: Arc, #[cfg(feature = "realtime")] error_callback: ErrorCallbackArc, #[cfg(feature = "realtime")] @@ -271,7 +296,7 @@ impl LocalProcessHandler { buffer_size: usize, input_data_callback: Option, output_data_callback: Option, - playing: Arc, + state: Arc, #[cfg(feature = "realtime")] error_callback: ErrorCallbackArc, ) -> Self { let temp_input_buffer = vec![0.0; in_ports.len() * buffer_size]; @@ -286,7 +311,7 @@ impl LocalProcessHandler { output_data_callback, temp_input_buffer, temp_output_buffer, - playing, + state, #[cfg(feature = "realtime")] error_callback, #[cfg(feature = "realtime")] @@ -308,7 +333,7 @@ impl jack::ProcessHandler for LocalProcessHandler { client: &jack::Client, process_scope: &jack::ProcessScope, ) -> jack::Control { - if !self.playing.load(Ordering::Relaxed) { + if StreamState::load(&self.state, Ordering::Relaxed) != StreamState::Playing { // JACK does not zero-fill output port buffers before calling the process handler for port in &mut self.out_ports { port.as_mut_slice(process_scope).fill(f32::EQUILIBRIUM); @@ -490,20 +515,25 @@ fn micros_to_stream_instant(micros: u64) -> StreamInstant { /// Receives notifications from the JACK server on JACK's notification thread (single-threaded). struct JackNotificationHandler { error_callback_ptr: ErrorCallbackArc, - init_sample_rate_flag: bool, + /// Shared with `Stream` and `LocalProcessHandler`. Errors are suppressed while + /// the state is `Initializing` (i.e. before the constructor has returned). + state: Arc, } impl JackNotificationHandler { - pub fn new(error_callback_ptr: ErrorCallbackArc) -> Self { + pub fn new(error_callback_ptr: ErrorCallbackArc, state: Arc) -> Self { JackNotificationHandler { error_callback_ptr, - init_sample_rate_flag: false, + state, } } } impl jack::NotificationHandler for JackNotificationHandler { unsafe fn shutdown(&mut self, _status: jack::ClientStatus, reason: &str) { + if StreamState::load(&self.state, Ordering::Acquire) == StreamState::Initializing { + return; + } emit_error( &self.error_callback_ptr, Error::with_message( @@ -514,32 +544,29 @@ impl jack::NotificationHandler for JackNotificationHandler { } fn sample_rate(&mut self, _: &jack::Client, srate: jack::Frames) -> jack::Control { - match self.init_sample_rate_flag { - false => { - // One of these notifications is sent every time a client is started. - self.init_sample_rate_flag = true; - jack::Control::Continue - } - true => { - // The JACK server has changed the sample rate, invalidating this stream. - // The stream configuration must be rebuilt with the new sample rate. - emit_error( - &self.error_callback_ptr, - Error::with_message( - ErrorKind::StreamInvalidated, - format!("JACK server changed sample rate to {srate} Hz"), - ), - ); - jack::Control::Quit - } + if StreamState::load(&self.state, Ordering::Acquire) == StreamState::Initializing { + // One of these notifications is sent every time a client is started. + return jack::Control::Continue; } + // The JACK server has changed the sample rate, invalidating this stream. + // The stream configuration must be rebuilt with the new sample rate. + emit_error( + &self.error_callback_ptr, + Error::with_message( + ErrorKind::StreamInvalidated, + format!("JACK server changed sample rate to {srate} Hz"), + ), + ); + jack::Control::Quit } fn xrun(&mut self, _: &jack::Client) -> jack::Control { - let _ = try_emit_error( - &self.error_callback_ptr, - Error::with_message(ErrorKind::Xrun, "JACK xrun detected"), - ); + if StreamState::load(&self.state, Ordering::Acquire) != StreamState::Initializing { + let _ = try_emit_error( + &self.error_callback_ptr, + Error::with_message(ErrorKind::Xrun, "JACK xrun detected"), + ); + } jack::Control::Continue } } From 230057539b71cbd480a0bd7c7888a67798a95634 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Thu, 14 May 2026 14:35:22 +0200 Subject: [PATCH 16/22] refactor: address review points --- src/host/aaudio/mod.rs | 2 -- src/host/asio/stream.rs | 44 +++++++++++++++++++---------- src/host/jack/stream.rs | 26 +++++++++++------ src/host/pipewire/device.rs | 56 +++++++++++++++++++++++++++++-------- 4 files changed, 92 insertions(+), 36 deletions(-) diff --git a/src/host/aaudio/mod.rs b/src/host/aaudio/mod.rs index f8d5e13ae..0c3037a0b 100644 --- a/src/host/aaudio/mod.rs +++ b/src/host/aaudio/mod.rs @@ -327,7 +327,6 @@ where let error_callback: ErrorCallbackArc = Arc::new(Mutex::new(error_callback)); let error_callback_for_stream = error_callback.clone(); - // RT check: performed on the audio thread once per stream. #[cfg(feature = "realtime")] let mut rt_checked = false; #[cfg(feature = "realtime")] @@ -407,7 +406,6 @@ where let error_callback: ErrorCallbackArc = Arc::new(Mutex::new(error_callback)); let error_callback_for_stream = error_callback.clone(); - // RT check: performed on the audio thread once per stream. #[cfg(feature = "realtime")] let mut rt_checked = false; #[cfg(feature = "realtime")] diff --git a/src/host/asio/stream.rs b/src/host/asio/stream.rs index 9e9ae93e8..fc7d0a3b9 100644 --- a/src/host/asio/stream.rs +++ b/src/host/asio/stream.rs @@ -184,13 +184,21 @@ impl Device { )); let state = Arc::new(AtomicU8::new(StreamState::Idle as u8)); - let driver_event_callback_id = self.add_event_callback( - &driver, - error_callback, - Arc::clone(&hardware_input_latency), - true, - Arc::clone(&state), - )?; + let driver_event_callback_id = self + .add_event_callback( + &driver, + error_callback, + Arc::clone(&hardware_input_latency), + true, + Arc::clone(&state), + ) + .map_err(|e| { + // Roll back the input stream stored by get_or_create_input_stream. + if let Ok(mut streams) = self.asio_streams.lock() { + streams.input = None; + } + e + })?; let state_cb = Arc::clone(&state); let asio_streams = self.asio_streams.clone(); @@ -504,13 +512,21 @@ impl Device { )); let state = Arc::new(AtomicU8::new(StreamState::Idle as u8)); - let driver_event_callback_id = self.add_event_callback( - &driver, - error_callback, - Arc::clone(&hardware_output_latency), - false, - Arc::clone(&state), - )?; + let driver_event_callback_id = self + .add_event_callback( + &driver, + error_callback, + Arc::clone(&hardware_output_latency), + false, + Arc::clone(&state), + ) + .map_err(|e| { + // Roll back the output stream stored by get_or_create_output_stream. + if let Ok(mut streams) = self.asio_streams.lock() { + streams.output = None; + } + e + })?; let state_cb = Arc::clone(&state); let asio_streams = self.asio_streams.clone(); diff --git a/src/host/jack/stream.rs b/src/host/jack/stream.rs index b900e1fa2..5ec5082f6 100644 --- a/src/host/jack/stream.rs +++ b/src/host/jack/stream.rs @@ -84,7 +84,11 @@ impl Stream { error_callback_ptr.clone(), ); - let notification_handler = JackNotificationHandler::new(error_callback_ptr, state.clone()); + let notification_handler = JackNotificationHandler::new( + error_callback_ptr, + state.clone(), + client.sample_rate() as jack::Frames, + ); let async_client = client .activate_async(notification_handler, input_process_handler) @@ -136,7 +140,11 @@ impl Stream { error_callback_ptr.clone(), ); - let notification_handler = JackNotificationHandler::new(error_callback_ptr, state.clone()); + let notification_handler = JackNotificationHandler::new( + error_callback_ptr, + state.clone(), + client.sample_rate() as jack::Frames, + ); let async_client = client .activate_async(notification_handler, output_process_handler) @@ -515,16 +523,20 @@ fn micros_to_stream_instant(micros: u64) -> StreamInstant { /// Receives notifications from the JACK server on JACK's notification thread (single-threaded). struct JackNotificationHandler { error_callback_ptr: ErrorCallbackArc, - /// Shared with `Stream` and `LocalProcessHandler`. Errors are suppressed while - /// the state is `Initializing` (i.e. before the constructor has returned). state: Arc, + configured_sample_rate: jack::Frames, } impl JackNotificationHandler { - pub fn new(error_callback_ptr: ErrorCallbackArc, state: Arc) -> Self { + pub fn new( + error_callback_ptr: ErrorCallbackArc, + state: Arc, + configured_sample_rate: jack::Frames, + ) -> Self { JackNotificationHandler { error_callback_ptr, state, + configured_sample_rate, } } } @@ -544,12 +556,10 @@ impl jack::NotificationHandler for JackNotificationHandler { } fn sample_rate(&mut self, _: &jack::Client, srate: jack::Frames) -> jack::Control { - if StreamState::load(&self.state, Ordering::Acquire) == StreamState::Initializing { + if srate == self.configured_sample_rate { // One of these notifications is sent every time a client is started. return jack::Control::Continue; } - // The JACK server has changed the sample rate, invalidating this stream. - // The stream configuration must be rebuilt with the new sample rate. emit_error( &self.error_callback_ptr, Error::with_message( diff --git a/src/host/pipewire/device.rs b/src/host/pipewire/device.rs index baa9dce5f..c6f651cba 100644 --- a/src/host/pipewire/device.rs +++ b/src/host/pipewire/device.rs @@ -321,7 +321,7 @@ impl DeviceTrait for Device { let (init_tx, init_rx) = std::sync::mpsc::channel::>(); let stream_ready = Arc::new(AtomicBool::new(false)); - let stream_ready_worker = stream_ready.clone(); + let stream_ready_worker = Arc::downgrade(&stream_ready); let device = self.clone(); let wait_timeout = timeout.unwrap_or(Duration::from_secs(2)); let initial_quantum = match config.buffer_size { @@ -425,9 +425,16 @@ impl DeviceTrait for Device { return; } - // Park until the builder calls signal_ready() after constructing the Stream. - while !stream_ready_worker.load(Ordering::Acquire) { - std::thread::park(); + // Park until the builder signals readiness, or it drops the stream_ready Arc. + loop { + match stream_ready_worker.upgrade() { + None => return, + Some(flag) if flag.load(Ordering::Acquire) => break, + Some(flag) => { + drop(flag); // release strong ref before parking + std::thread::park(); + } + } } #[cfg(feature = "realtime")] @@ -452,13 +459,22 @@ impl DeviceTrait for Device { format!("failed to create thread: {e}"), ) })?; + let worker_thread = handle.thread().clone(); - init_rx.recv_timeout(wait_timeout).unwrap_or_else(|_| { + let init_result = init_rx.recv_timeout(wait_timeout).unwrap_or_else(|_| { Err(Error::with_message( ErrorKind::DeviceNotAvailable, "PipeWire timed out", )) - })?; + }); + + if let Err(e) = init_result { + // Drop stream_ready first so the Weak on the worker side becomes invalid, then unpark + // the worker so it can observe the invalidation and exit cleanly. + drop(stream_ready); + worker_thread.unpark(); + return Err(e); + } let stream = Stream::new(handle, pw_play_tx, last_quantum, start, stream_ready); stream.signal_ready(); @@ -481,7 +497,7 @@ impl DeviceTrait for Device { let (init_tx, init_rx) = std::sync::mpsc::channel::>(); let stream_ready = Arc::new(AtomicBool::new(false)); - let stream_ready_worker = stream_ready.clone(); + let stream_ready_worker = Arc::downgrade(&stream_ready); let device = self.clone(); let wait_timeout = timeout.unwrap_or(Duration::from_secs(2)); let initial_quantum = match config.buffer_size { @@ -585,9 +601,16 @@ impl DeviceTrait for Device { return; } - // Park until the builder calls signal_ready() after constructing the Stream. - while !stream_ready_worker.load(Ordering::Acquire) { - std::thread::park(); + // Park until the builder signals readiness, or it drops the stream_ready Arc. + loop { + match stream_ready_worker.upgrade() { + None => return, + Some(flag) if flag.load(Ordering::Acquire) => break, + Some(flag) => { + drop(flag); // release strong ref before parking + std::thread::park(); + } + } } #[cfg(feature = "realtime")] @@ -611,13 +634,22 @@ impl DeviceTrait for Device { format!("failed to create thread: {e}"), ) })?; + let worker_thread = handle.thread().clone(); - init_rx.recv_timeout(wait_timeout).unwrap_or_else(|_| { + let init_result = init_rx.recv_timeout(wait_timeout).unwrap_or_else(|_| { Err(Error::with_message( ErrorKind::DeviceNotAvailable, "PipeWire timed out", )) - })?; + }); + + if let Err(e) = init_result { + // Drop stream_ready first so the Weak on the worker side becomes invalid, then unpark + // the worker so it can observe the invalidation and exit cleanly. + drop(stream_ready); + worker_thread.unpark(); + return Err(e); + } let stream = Stream::new(handle, pw_play_tx, last_quantum, start, stream_ready); stream.signal_ready(); From 4b53bcb9c183b66abb18d502af336bc4981321ab Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Thu, 14 May 2026 20:08:27 +0200 Subject: [PATCH 17/22] refactor: introduce Latch for stream readiness Add a host/latch.rs utility and replace per-backend Arc "stream_ready" gates with a Latch/Waiter pair. --- src/host/alsa/mod.rs | 43 +++-- src/host/asio/stream.rs | 21 +-- src/host/coreaudio/ios/mod.rs | 31 +++- .../coreaudio/ios/session_event_manager.rs | 53 +++--- src/host/coreaudio/macos/device.rs | 14 +- src/host/coreaudio/macos/mod.rs | 154 ++++++++++++------ src/host/jack/stream.rs | 16 +- src/host/latch.rs | 95 +++++++++++ src/host/mod.rs | 22 +++ src/host/pipewire/device.rs | 55 ++----- src/host/pipewire/stream.rs | 18 +- src/host/pulseaudio/stream.rs | 47 +++--- src/host/wasapi/stream.rs | 52 +++--- 13 files changed, 401 insertions(+), 220 deletions(-) create mode 100644 src/host/latch.rs diff --git a/src/host/alsa/mod.rs b/src/host/alsa/mod.rs index aafc1b2d3..1cce43e66 100644 --- a/src/host/alsa/mod.rs +++ b/src/host/alsa/mod.rs @@ -22,6 +22,7 @@ use crate::{ host::{ equilibrium::{fill_equilibrium, DSD_EQUILIBRIUM_BYTE, U8_EQUILIBRIUM_BYTE}, frames_to_duration, + latch::Latch, }, iter::{SupportedInputConfigs, SupportedOutputConfigs}, traits::{DeviceTrait, HostTrait, StreamTrait}, @@ -776,9 +777,9 @@ pub struct Stream { /// `trigger.wakeup()` never writes to a closed pipe, even if the worker exited early. _rx: Arc, - /// Gate that prevents the worker thread from firing callbacks until the caller has received + /// Latch that prevents the worker thread from firing callbacks until the caller has received /// the `Stream` handle. - stream_ready: Arc, + latch: Latch, } // Compile-time assertion that Stream is Send and Sync @@ -1257,12 +1258,9 @@ fn htstamp_elapsed(status: &alsa::pcm::Status, origin: libc::timespec) -> Stream } impl Stream { - /// Unblocks the worker thread so it can begin processing audio callbacks. + /// Releases the latch so the worker thread can begin processing audio callbacks. pub(crate) fn signal_ready(&self) { - self.stream_ready.store(true, Ordering::Release); - if let Some(handle) = &self.thread { - handle.thread().unpark(); - } + self.latch.release(); } fn new_input( @@ -1279,17 +1277,15 @@ impl Stream { let rx_thread = rx.clone(); let stream = inner.clone(); - // `stream_ready` is signalled just before the `Stream` is returned so the worker cannot - // fire any callbacks before the caller has the handle. - let stream_ready = Arc::new(AtomicBool::new(false)); - let stream_ready_worker = stream_ready.clone(); + // The latch is released just before the `Stream` is returned so the worker cannot fire any + // callbacks before the caller has the handle. + let mut latch = Latch::new(); + let waiter = latch.waiter(); let thread = thread::Builder::new() .name("cpal_alsa_in".to_owned()) .spawn(move || { - while !stream_ready_worker.load(Ordering::Acquire) { - std::thread::park(); - } + waiter.wait(); input_stream_worker( rx_thread, &stream, @@ -1299,12 +1295,14 @@ impl Stream { ); }) .unwrap(); + latch.add_thread(thread.thread().clone()); + Self { thread: Some(thread), inner, trigger: tx, _rx: rx, - stream_ready, + latch, } } @@ -1322,17 +1320,15 @@ impl Stream { let rx_thread = rx.clone(); let stream = inner.clone(); - // `stream_ready` is signalled just before the `Stream` is returned so the worker cannot - // fire any callbacks before the caller has the handle. - let stream_ready = Arc::new(AtomicBool::new(false)); - let stream_ready_worker = stream_ready.clone(); + // The latch is released just before the `Stream` is returned so the worker cannot fire any + // callbacks before the caller has the handle. + let mut latch = Latch::new(); + let waiter = latch.waiter(); let thread = thread::Builder::new() .name("cpal_alsa_out".to_owned()) .spawn(move || { - while !stream_ready_worker.load(Ordering::Acquire) { - std::thread::park(); - } + waiter.wait(); output_stream_worker( rx_thread, &stream, @@ -1342,13 +1338,14 @@ impl Stream { ); }) .unwrap(); + latch.add_thread(thread.thread().clone()); Self { thread: Some(thread), inner, trigger: tx, _rx: rx, - stream_ready, + latch, } } } diff --git a/src/host/asio/stream.rs b/src/host/asio/stream.rs index fc7d0a3b9..014be96c3 100644 --- a/src/host/asio/stream.rs +++ b/src/host/asio/stream.rs @@ -12,7 +12,7 @@ use std::{ use self::num_traits::{FromPrimitive, PrimInt}; use super::Device; use crate::{ - host::{com, frames_to_duration}, + host::{com, error_emit::try_emit_error, frames_to_duration}, BufferSize, Data, Error, ErrorKind, FrameCount, InputCallbackInfo, InputStreamTimestamp, OutputCallbackInfo, OutputStreamTimestamp, SampleFormat, SampleRate, StreamConfig, StreamInstant, I24, @@ -192,12 +192,11 @@ impl Device { true, Arc::clone(&state), ) - .map_err(|e| { + .inspect_err(|_| { // Roll back the input stream stored by get_or_create_input_stream. if let Ok(mut streams) = self.asio_streams.lock() { streams.input = None; } - e })?; let state_cb = Arc::clone(&state); @@ -437,6 +436,9 @@ impl Device { if let Err(e) = driver.start() { driver.remove_event_callback(driver_event_callback_id); driver.remove_callback(callback_id); + if let Ok(mut streams) = asio_streams.lock() { + streams.input = None; + } return Err(build_stream_err(e)); } @@ -520,12 +522,11 @@ impl Device { false, Arc::clone(&state), ) - .map_err(|e| { + .inspect_err(|_| { // Roll back the output stream stored by get_or_create_output_stream. if let Ok(mut streams) = self.asio_streams.lock() { streams.output = None; } - e })?; let state_cb = Arc::clone(&state); @@ -814,6 +815,9 @@ impl Device { if let Err(e) = driver.start() { driver.remove_event_callback(driver_event_callback_id); driver.remove_callback(callback_id); + if let Ok(mut streams) = asio_streams.lock() { + streams.output = None; + } return Err(build_stream_err(e)); } @@ -1015,11 +1019,8 @@ impl Device { } sys::AsioMessageSelectors::kAsioOverload => { if StreamState::load(&state) == StreamState::Playing { - error_callback_shared - .lock() - .unwrap_or_else(|e| e.into_inner())( - Error::new(ErrorKind::Xrun) - ); + let _ = + try_emit_error(&error_callback_shared, Error::new(ErrorKind::Xrun)); } true } diff --git a/src/host/coreaudio/ios/mod.rs b/src/host/coreaudio/ios/mod.rs index 3e0f5769c..377074a92 100644 --- a/src/host/coreaudio/ios/mod.rs +++ b/src/host/coreaudio/ios/mod.rs @@ -174,7 +174,7 @@ impl DeviceTrait for Device { let device_buffer_frames = Some(get_device_buffer_frames()); let error_callback: ErrorCallbackArc = Arc::new(Mutex::new(error_callback)); - let session_manager = SessionEventManager::new(error_callback.clone()); + let session_manager = SessionEventManager::new(error_callback.clone(), Latch::new()); // Set up input callback setup_input_callback( @@ -188,13 +188,15 @@ impl DeviceTrait for Device { }, )?; - Ok(Stream::new( + let stream = Stream::new( StreamInner { playing: false, audio_unit, }, session_manager, - )) + ); + stream.signal_ready(); + Ok(stream) } /// Create an output stream. @@ -217,7 +219,7 @@ impl DeviceTrait for Device { let device_buffer_frames = Some(get_device_buffer_frames()); let error_callback: ErrorCallbackArc = Arc::new(Mutex::new(error_callback)); - let session_manager = SessionEventManager::new(error_callback.clone()); + let session_manager = SessionEventManager::new(error_callback.clone(), Latch::new()); // Set up output callback setup_output_callback( @@ -231,28 +233,41 @@ impl DeviceTrait for Device { }, )?; - Ok(Stream::new( + let stream = Stream::new( StreamInner { playing: false, audio_unit, }, session_manager, - )) + ); + stream.signal_ready(); + Ok(stream) } } pub struct Stream { inner: Mutex, - _session_manager: SessionEventManager, + session_manager: SessionEventManager, } impl Stream { fn new(inner: StreamInner, session_manager: SessionEventManager) -> Self { Self { inner: Mutex::new(inner), - _session_manager: session_manager, + session_manager, } } + + fn signal_ready(&self) { + self.session_manager.signal_ready(); + } +} + +impl Drop for Stream { + fn drop(&mut self) { + // Ensure the latch is released even if signal_ready() was never called (error path). + self.session_manager.signal_ready(); + } } impl StreamTrait for Stream { diff --git a/src/host/coreaudio/ios/session_event_manager.rs b/src/host/coreaudio/ios/session_event_manager.rs index 7f9665d44..f40a63943 100644 --- a/src/host/coreaudio/ios/session_event_manager.rs +++ b/src/host/coreaudio/ios/session_event_manager.rs @@ -12,7 +12,7 @@ use objc2_avf_audio::{ use objc2_foundation::{NSNotification, NSNotificationCenter, NSNumber, NSString}; use crate::{ - host::{emit_error, ErrorCallbackArc}, + host::{emit_error, latch::Latch, ErrorCallbackArc}, Error, ErrorKind, }; @@ -46,6 +46,7 @@ unsafe fn route_change_error(notification: &NSNotification) -> Option { } pub(super) struct SessionEventManager { + latch: Latch, observers: Vec< objc2::rc::Retained>, >, @@ -57,15 +58,19 @@ unsafe impl Send for SessionEventManager {} unsafe impl Sync for SessionEventManager {} impl SessionEventManager { - pub(super) fn new(error_callback: ErrorCallbackArc) -> Self { + pub(super) fn new(error_callback: ErrorCallbackArc, latch: Latch) -> Self { let nc = NSNotificationCenter::defaultCenter(); let mut observers = Vec::new(); + let waiter = latch.waiter(); { let cb = error_callback.clone(); + let w = waiter.clone(); let block = RcBlock::new(move |notif: NonNull| { - if let Some(err) = unsafe { route_change_error(notif.as_ref()) } { - emit_error(&cb, err); + if w.is_released() { + if let Some(err) = unsafe { route_change_error(notif.as_ref()) } { + emit_error(&cb, err); + } } }); if let Some(name) = unsafe { AVAudioSessionRouteChangeNotification } { @@ -78,14 +83,17 @@ impl SessionEventManager { { let cb = error_callback.clone(); + let w = waiter.clone(); let block = RcBlock::new(move |_: NonNull| { - emit_error( - &cb, - Error::with_message( - ErrorKind::DeviceNotAvailable, - "audio media services were lost", - ), - ); + if w.is_released() { + emit_error( + &cb, + Error::with_message( + ErrorKind::DeviceNotAvailable, + "audio media services were lost", + ), + ); + } }); if let Some(name) = unsafe { AVAudioSessionMediaServicesWereLostNotification } { let observer = unsafe { @@ -97,14 +105,17 @@ impl SessionEventManager { { let cb = error_callback.clone(); + let w = waiter; let block = RcBlock::new(move |_: NonNull| { - emit_error( - &cb, - Error::with_message( - ErrorKind::StreamInvalidated, - "audio media services were reset", - ), - ); + if w.is_released() { + emit_error( + &cb, + Error::with_message( + ErrorKind::StreamInvalidated, + "audio media services were reset", + ), + ); + } }); if let Some(name) = unsafe { AVAudioSessionMediaServicesWereResetNotification } { let observer = unsafe { @@ -114,7 +125,11 @@ impl SessionEventManager { } } - Self { observers } + Self { latch, observers } + } + + pub(super) fn signal_ready(&self) { + self.latch.release(); } } diff --git a/src/host/coreaudio/macos/device.rs b/src/host/coreaudio/macos/device.rs index b264408c6..61c638d85 100644 --- a/src/host/coreaudio/macos/device.rs +++ b/src/host/coreaudio/macos/device.rs @@ -41,7 +41,7 @@ use objc2_core_foundation::{CFString, Type}; pub use super::enumerate::{SupportedInputConfigs, SupportedOutputConfigs}; use super::{ asbd_from_config, check_os_status, host_time_to_stream_instant, DefaultOutputMonitor, - DisconnectManager, Stream, + DisconnectManager, Monitor, Stream, }; use crate::{ host::{ @@ -831,12 +831,14 @@ impl Device { _loopback_device: loopback_aggregate, })); let weak_inner = Arc::downgrade(&inner_arc); - let monitor: Box = Box::new(DisconnectManager::new( + let monitor: Box = Box::new(DisconnectManager::new( self.audio_device_id, weak_inner, error_callback_disconnect, )?); - Ok(Stream::new(inner_arc, monitor)) + let stream = Stream::new(inner_arc, monitor); + stream.signal_ready(); + Ok(stream) } fn build_output_stream_raw( @@ -933,7 +935,7 @@ impl Device { _loopback_device: None, })); let weak_inner = Arc::downgrade(&inner_arc); - let monitor: Box = if matches!(mode, AudioUnitMode::DefaultOutput) { + let monitor: Box = if matches!(mode, AudioUnitMode::DefaultOutput) { Box::new(DefaultOutputMonitor::new(weak_inner, error_callback)?) } else { Box::new(DisconnectManager::new( @@ -942,7 +944,9 @@ impl Device { error_callback, )?) }; - Ok(Stream::new(inner_arc, monitor)) + let stream = Stream::new(inner_arc, monitor); + stream.signal_ready(); + Ok(stream) } } diff --git a/src/host/coreaudio/macos/mod.rs b/src/host/coreaudio/macos/mod.rs index 620a8e2d4..d39b2d57d 100644 --- a/src/host/coreaudio/macos/mod.rs +++ b/src/host/coreaudio/macos/mod.rs @@ -13,7 +13,7 @@ use property_listener::AudioObjectPropertyListener; pub use self::enumerate::{default_input_device, default_output_device, Devices}; use super::{asbd_from_config, check_os_status, host_time_to_stream_instant, OSStatus}; use crate::{ - host::{coreaudio::macos::loopback::LoopbackDevice, emit_error}, + host::{coreaudio::macos::loopback::LoopbackDevice, emit_error, latch::Latch}, traits::{HostTrait, StreamTrait}, Error, ErrorKind, FrameCount, ResultExt, StreamInstant, }; @@ -94,6 +94,13 @@ fn spawn_property_listener_thread( Ok((change_rx, shutdown_tx)) } +/// A device monitor that can signal when the owning `Stream` handle has been returned to the +/// caller, allowing the delivery thread to start processing events. +pub(super) trait Monitor: Send + Sync { + /// Unblocks the delivery thread. Called after `Stream::new()` and from `Stream::drop()`. + fn signal_ready(&self); +} + /// Manages device disconnection listener on a dedicated thread to ensure the /// AudioObjectPropertyListener is always created and dropped on the same thread. /// This avoids potential threading issues with CoreAudio APIs. @@ -104,6 +111,7 @@ fn spawn_property_listener_thread( /// /// The dedicated thread architecture ensures `Stream` can implement `Send`. struct DisconnectManager { + latch: Latch, _shutdown_tx: mpsc::Sender<()>, } @@ -167,31 +175,55 @@ impl DisconnectManager { ) })??; - std::thread::spawn(move || { - while let Ok(err) = disconnect_rx.recv() { - if let Some(stream_arc) = stream_weak.upgrade() { - if let Ok(mut stream_inner) = stream_arc.try_lock() { - let _ = stream_inner.pause(); + let mut latch = Latch::new(); + let waiter = latch.waiter(); + + let handle = std::thread::Builder::new() + .name("cpal-coreaudio-disconnect".into()) + .spawn(move || { + // If the Latch is dropped without being released (error path), exit cleanly. + if !waiter.wait() { + return; + } + while let Ok(err) = disconnect_rx.recv() { + if let Some(stream_arc) = stream_weak.upgrade() { + if let Ok(mut stream_inner) = stream_arc.try_lock() { + let _ = stream_inner.pause(); + } + emit_error(&error_callback, err); + } else { + break; } - emit_error(&error_callback, err); - } else { - break; } - } - }); - + }) + .map_err(|e| { + Error::with_message( + ErrorKind::ResourceExhausted, + format!("failed to spawn disconnect delivery thread: {e}"), + ) + })?; + + latch.add_thread(handle.thread().clone()); Ok(DisconnectManager { + latch, _shutdown_tx: shutdown_tx, }) } } +impl Monitor for DisconnectManager { + fn signal_ready(&self) { + self.latch.release(); + } +} + /// Manages the system default output device change listener on a dedicated thread. /// /// When the system default output device changes: /// - If a new valid default exists, AudioUnit reroutes and `DeviceChanged` is reported. /// - If there is no new default, the stream is paused and `DeviceNotAvailable` is reported. struct DefaultOutputMonitor { + latch: Latch, _shutdown_tx: mpsc::Sender<()>, } @@ -209,41 +241,63 @@ impl DefaultOutputMonitor { }, )?; - std::thread::spawn(move || { - while let Ok(()) = change_rx.recv() { - let Some(arc) = stream_weak.upgrade() else { - break; - }; - if default_output_device().is_none() { - if let Ok(mut inner) = arc.try_lock() { - let _ = inner.pause(); + let mut latch = Latch::new(); + let waiter = latch.waiter(); + + let handle = std::thread::Builder::new() + .name("cpal-coreaudio-default-output".into()) + .spawn(move || { + if !waiter.wait() { + return; + } + while let Ok(()) = change_rx.recv() { + let Some(arc) = stream_weak.upgrade() else { + break; + }; + if default_output_device().is_none() { + if let Ok(mut inner) = arc.try_lock() { + let _ = inner.pause(); + } + emit_error( + &error_callback, + Error::with_message( + ErrorKind::DeviceNotAvailable, + "no default output device", + ), + ); + } else { + // DefaultOutput AudioUnit rerouted automatically; notify the caller. + emit_error( + &error_callback, + Error::with_message( + ErrorKind::DeviceChanged, + "default output device changed", + ), + ); } - emit_error( - &error_callback, - Error::with_message( - ErrorKind::DeviceNotAvailable, - "no default output device", - ), - ); - } else { - // DefaultOutput AudioUnit rerouted automatically; notify the caller. - emit_error( - &error_callback, - Error::with_message( - ErrorKind::DeviceChanged, - "default output device changed", - ), - ); } - } - }); - + }) + .map_err(|e| { + Error::with_message( + ErrorKind::ResourceExhausted, + format!("failed to spawn default-output monitor thread: {e}"), + ) + })?; + + latch.add_thread(handle.thread().clone()); Ok(DefaultOutputMonitor { + latch, _shutdown_tx: shutdown_tx, }) } } +impl Monitor for DefaultOutputMonitor { + fn signal_ready(&self) { + self.latch.release(); + } +} + struct StreamInner { playing: bool, audio_unit: AudioUnit, @@ -277,17 +331,23 @@ impl StreamInner { pub struct Stream { inner: Arc>, - // Holds the device monitor (either DisconnectManager or DefaultOutputMonitor) to keep it - // alive for the lifetime of the stream. - _monitor: Box, + monitor: Box, } impl Stream { - fn new(inner: Arc>, monitor: Box) -> Self { - Self { - inner, - _monitor: monitor, - } + fn new(inner: Arc>, monitor: Box) -> Self { + Self { inner, monitor } + } + + fn signal_ready(&self) { + self.monitor.signal_ready(); + } +} + +impl Drop for Stream { + fn drop(&mut self) { + // Unblock monitor delivery threads if the stream is dropped early. + self.monitor.signal_ready(); } } diff --git a/src/host/jack/stream.rs b/src/host/jack/stream.rs index 5ec5082f6..cfcbc40ec 100644 --- a/src/host/jack/stream.rs +++ b/src/host/jack/stream.rs @@ -560,13 +560,15 @@ impl jack::NotificationHandler for JackNotificationHandler { // One of these notifications is sent every time a client is started. return jack::Control::Continue; } - emit_error( - &self.error_callback_ptr, - Error::with_message( - ErrorKind::StreamInvalidated, - format!("JACK server changed sample rate to {srate} Hz"), - ), - ); + if StreamState::load(&self.state, Ordering::Acquire) != StreamState::Initializing { + emit_error( + &self.error_callback_ptr, + Error::with_message( + ErrorKind::StreamInvalidated, + format!("JACK server changed sample rate to {srate} Hz"), + ), + ); + } jack::Control::Quit } diff --git a/src/host/latch.rs b/src/host/latch.rs new file mode 100644 index 000000000..bf3b86b70 --- /dev/null +++ b/src/host/latch.rs @@ -0,0 +1,95 @@ +//! Stream-readiness latch used by backends with dedicated worker threads. +//! +//! Prevents worker threads from invoking user callbacks before the `Stream` handle has been +//! returned to the caller. + +use std::{ + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Weak, + }, + thread::Thread, +}; + +/// Signals worker threads that the stream handle has been given to the caller. +pub(crate) struct Latch { + /// `Option` so `Drop` can move it out before unparking, closing the window where a thread + /// could wake, see the Arc still alive (flag=false), re-park, then be orphaned. + flag: Option>, + threads: Vec, +} + +/// Held by a worker thread. Parks until the matching [`Latch`] is released. +#[derive(Clone)] +pub(crate) struct LatchWaiter(Weak); + +impl Latch { + /// Creates a new stream-readiness latch. + pub(crate) fn new() -> Self { + Self { + flag: Some(Arc::new(AtomicBool::new(false))), + threads: Vec::new(), + } + } + + /// Returns a waiter that unblocks when this latch is released. + pub(crate) fn waiter(&self) -> LatchWaiter { + LatchWaiter(Arc::downgrade( + self.flag + .as_ref() + .expect("waiter called on a dropped Latch"), + )) + } + + /// Registers a thread to be unparked when [`release`](Self::release) is called. + pub(crate) fn add_thread(&mut self, thread: Thread) { + self.threads.push(thread); + } + + /// Releases the latch and unparks all registered threads. + pub(crate) fn release(&self) { + if let Some(flag) = &self.flag { + flag.store(true, Ordering::Release); + } + for t in &self.threads { + t.unpark(); + } + } +} + +impl Drop for Latch { + fn drop(&mut self) { + // Invalidate the Arc *before* unparking so waiters see upgrade() == None and exit cleanly + // on the error path (latch dropped without being released). + drop(self.flag.take()); + for t in &self.threads { + t.unpark(); + } + } +} + +impl LatchWaiter { + /// Parks the calling thread until the latch is released or dropped without releasing. + /// + /// Returns `true` if the stream is ready, `false` if the [`Latch`] was dropped before release. + pub(crate) fn wait(&self) -> bool { + loop { + match self.0.upgrade() { + None => return false, + Some(flag) if flag.load(Ordering::Acquire) => return true, + Some(flag) => { + drop(flag); // release strong ref before parking + std::thread::park(); + } + } + } + } + + /// Returns `true` if the latch has already been released. + #[allow(dead_code)] + pub(crate) fn is_released(&self) -> bool { + self.0 + .upgrade() + .map_or(false, |f| f.load(Ordering::Acquire)) + } +} diff --git a/src/host/mod.rs b/src/host/mod.rs index 2ccf71c71..307a904a2 100644 --- a/src/host/mod.rs +++ b/src/host/mod.rs @@ -90,6 +90,28 @@ pub(crate) mod custom; )))] pub(crate) mod null; +#[cfg(any( + target_os = "android", + target_vendor = "apple", + target_os = "windows", + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + all( + feature = "jack", + any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "macos", + target_os = "windows", + ) + ), +))] +pub(crate) mod latch; + /// Shared error-callback type that hands the callback across thread boundaries. #[allow(dead_code)] pub(crate) type ErrorCallbackArc = std::sync::Arc>; diff --git a/src/host/pipewire/device.rs b/src/host/pipewire/device.rs index c6f651cba..5e69abe1d 100644 --- a/src/host/pipewire/device.rs +++ b/src/host/pipewire/device.rs @@ -2,7 +2,7 @@ use std::{ cell::RefCell, rc::Rc, sync::{ - atomic::{AtomicBool, AtomicU64, Ordering}, + atomic::{AtomicU64, Ordering}, Arc, }, thread, @@ -21,6 +21,7 @@ use super::stream::Stream; use crate::{ host::{ emit_error, + latch::Latch, pipewire::{ stream::{ DefaultDeviceMonitor, PwInitGuard, StreamCommand, StreamData, SUPPORTED_FORMATS, @@ -320,8 +321,8 @@ impl DeviceTrait for Device { let (pw_play_tx, pw_play_rx) = pw::channel::channel::(); let (init_tx, init_rx) = std::sync::mpsc::channel::>(); - let stream_ready = Arc::new(AtomicBool::new(false)); - let stream_ready_worker = Arc::downgrade(&stream_ready); + let mut latch = Latch::new(); + let waiter = latch.waiter(); let device = self.clone(); let wait_timeout = timeout.unwrap_or(Duration::from_secs(2)); let initial_quantum = match config.buffer_size { @@ -425,16 +426,9 @@ impl DeviceTrait for Device { return; } - // Park until the builder signals readiness, or it drops the stream_ready Arc. - loop { - match stream_ready_worker.upgrade() { - None => return, - Some(flag) if flag.load(Ordering::Acquire) => break, - Some(flag) => { - drop(flag); // release strong ref before parking - std::thread::park(); - } - } + // If the Latch is dropped without being released (error path), exit cleanly. + if !waiter.wait() { + return; } #[cfg(feature = "realtime")] @@ -459,7 +453,6 @@ impl DeviceTrait for Device { format!("failed to create thread: {e}"), ) })?; - let worker_thread = handle.thread().clone(); let init_result = init_rx.recv_timeout(wait_timeout).unwrap_or_else(|_| { Err(Error::with_message( @@ -469,14 +462,12 @@ impl DeviceTrait for Device { }); if let Err(e) = init_result { - // Drop stream_ready first so the Weak on the worker side becomes invalid, then unpark - // the worker so it can observe the invalidation and exit cleanly. - drop(stream_ready); - worker_thread.unpark(); + drop(latch); return Err(e); } - let stream = Stream::new(handle, pw_play_tx, last_quantum, start, stream_ready); + latch.add_thread(handle.thread().clone()); + let stream = Stream::new(handle, pw_play_tx, last_quantum, start, latch); stream.signal_ready(); Ok(stream) } @@ -496,8 +487,8 @@ impl DeviceTrait for Device { let (pw_play_tx, pw_play_rx) = pw::channel::channel::(); let (init_tx, init_rx) = std::sync::mpsc::channel::>(); - let stream_ready = Arc::new(AtomicBool::new(false)); - let stream_ready_worker = Arc::downgrade(&stream_ready); + let mut latch = Latch::new(); + let waiter = latch.waiter(); let device = self.clone(); let wait_timeout = timeout.unwrap_or(Duration::from_secs(2)); let initial_quantum = match config.buffer_size { @@ -601,16 +592,9 @@ impl DeviceTrait for Device { return; } - // Park until the builder signals readiness, or it drops the stream_ready Arc. - loop { - match stream_ready_worker.upgrade() { - None => return, - Some(flag) if flag.load(Ordering::Acquire) => break, - Some(flag) => { - drop(flag); // release strong ref before parking - std::thread::park(); - } - } + // If the Latch is dropped without being released (error path), exit cleanly. + if !waiter.wait() { + return; } #[cfg(feature = "realtime")] @@ -634,7 +618,6 @@ impl DeviceTrait for Device { format!("failed to create thread: {e}"), ) })?; - let worker_thread = handle.thread().clone(); let init_result = init_rx.recv_timeout(wait_timeout).unwrap_or_else(|_| { Err(Error::with_message( @@ -644,14 +627,12 @@ impl DeviceTrait for Device { }); if let Err(e) = init_result { - // Drop stream_ready first so the Weak on the worker side becomes invalid, then unpark - // the worker so it can observe the invalidation and exit cleanly. - drop(stream_ready); - worker_thread.unpark(); + drop(latch); return Err(e); } - let stream = Stream::new(handle, pw_play_tx, last_quantum, start, stream_ready); + latch.add_thread(handle.thread().clone()); + let stream = Stream::new(handle, pw_play_tx, last_quantum, start, latch); stream.signal_ready(); Ok(stream) } diff --git a/src/host/pipewire/stream.rs b/src/host/pipewire/stream.rs index 75110d751..fd70c3c1b 100644 --- a/src/host/pipewire/stream.rs +++ b/src/host/pipewire/stream.rs @@ -28,8 +28,8 @@ use pipewire::{ use crate::{ host::{ - emit_error, equilibrium::fill_equilibrium, frames_to_duration, try_emit_error, - ErrorCallbackArc, + emit_error, equilibrium::fill_equilibrium, frames_to_duration, latch::Latch, + try_emit_error, ErrorCallbackArc, }, traits::StreamTrait, Data, Error, ErrorKind, FrameCount, InputCallbackInfo, InputStreamTimestamp, @@ -79,7 +79,7 @@ pub struct Stream { controller: pw::channel::Sender, last_quantum: Arc, start: Instant, - stream_ready: Arc, + latch: Latch, } impl Stream { @@ -88,24 +88,20 @@ impl Stream { controller: pw::channel::Sender, last_quantum: Arc, start: Instant, - stream_ready: Arc, + latch: Latch, ) -> Self { Self { handle: Some(handle), controller, last_quantum, start, - stream_ready, + latch, } } - /// Unblocks the worker thread so it can begin processing audio callbacks. - /// Idempotent; does nothing if the worker is already running. + /// Releases the latch so the worker thread can begin processing audio callbacks. pub fn signal_ready(&self) { - self.stream_ready.store(true, Ordering::Release); - if let Some(handle) = &self.handle { - handle.thread().unpark(); - } + self.latch.release(); } } diff --git a/src/host/pulseaudio/stream.rs b/src/host/pulseaudio/stream.rs index ddd2d89c0..0c796b249 100644 --- a/src/host/pulseaudio/stream.rs +++ b/src/host/pulseaudio/stream.rs @@ -11,7 +11,7 @@ use futures::FutureExt as _; use pulseaudio::{protocol, AsPlaybackSource}; use crate::{ - host::{emit_error, ErrorCallbackArc}, + host::{emit_error, latch::Latch, ErrorCallbackArc}, traits::StreamTrait, Data, Error, ErrorKind, FrameCount, InputCallbackInfo, InputStreamTimestamp, OutputCallbackInfo, OutputStreamTimestamp, SampleFormat, StreamInstant, @@ -57,7 +57,7 @@ enum StreamInner { pub struct Stream { inner: StreamInner, workers: Vec>, - ready: Arc, + latch: Latch, } impl Drop for Stream { @@ -248,15 +248,13 @@ impl Stream { let error_callback_clone = error_callback.clone(); let cancel_driver = handle.cancel.clone(); - // `stream_ready` is signalled just before the `Stream` is returned so the driver and - // latency threads cannot fire any callbacks before the caller has the handle. - let stream_ready = Arc::new(AtomicBool::new(false)); - let stream_ready_driver = stream_ready.clone(); + // The latch is released just before the `Stream` is returned so the driver and latency + // threads cannot fire any callbacks before the caller has the handle. + let mut latch = Latch::new(); + let waiter_driver = latch.waiter(); let driver_handle = std::thread::spawn(move || { - while !stream_ready_driver.load(Ordering::Acquire) { - std::thread::park(); - } + waiter_driver.wait(); if let Err(e) = block_on(stream_clone.play_all()) { // A server playback error is expected when the client // closes their stream. No need to report it back to @@ -273,11 +271,9 @@ impl Stream { let latency_clone = current_latency_micros.clone(); let poll_clone = last_poll_micros.clone(); - let stream_ready_latency = stream_ready.clone(); + let waiter_latency = latch.waiter(); let latency_handle = std::thread::spawn(move || { - while !stream_ready_latency.load(Ordering::Acquire) { - std::thread::park(); - } + waiter_latency.wait(); loop { if cancel_thread.load(Ordering::Relaxed) { break; @@ -313,10 +309,12 @@ impl Stream { } }); + latch.add_thread(driver_handle.thread().clone()); + latch.add_thread(latency_handle.thread().clone()); Ok(Self { inner: StreamInner::Playback(stream, start, handle), workers: vec![driver_handle, latency_handle], - ready: stream_ready, + latch, }) } @@ -405,15 +403,13 @@ impl Stream { let latency_clone = current_latency_micros.clone(); let poll_clone = last_poll_micros.clone(); - // `stream_ready` is signalled just before the `Stream` is returned so the latency thread - // cannot fire any callbacks before the caller has the handle. - let stream_ready = Arc::new(AtomicBool::new(false)); - let stream_ready_latency = stream_ready.clone(); + // The latch is released just before the `Stream` is returned so the latency thread cannot + // fire any callbacks before the caller has the handle. + let mut latch = Latch::new(); + let waiter_latency = latch.waiter(); let latency_handle = std::thread::spawn(move || { - while !stream_ready_latency.load(Ordering::Acquire) { - std::thread::park(); - } + waiter_latency.wait(); loop { if cancel_thread.load(Ordering::Relaxed) { break; @@ -449,18 +445,17 @@ impl Stream { } }); + latch.add_thread(latency_handle.thread().clone()); Ok(Self { inner: StreamInner::Record(stream, start, handle), workers: vec![latency_handle], - ready: stream_ready, + latch, }) } + /// Releases the latch so the worker thread can begin processing audio callbacks. pub(crate) fn signal_ready(&self) { - self.ready.store(true, Ordering::Release); - for handle in &self.workers { - handle.thread().unpark(); - } + self.latch.release(); } } diff --git a/src/host/wasapi/stream.rs b/src/host/wasapi/stream.rs index eaefb1a39..dbb1b9195 100644 --- a/src/host/wasapi/stream.rs +++ b/src/host/wasapi/stream.rs @@ -18,7 +18,10 @@ use windows::Win32::{ }; use crate::{ - host::{emit_error, equilibrium::fill_equilibrium, frames_to_duration, ErrorCallbackArc}, + host::{ + emit_error, equilibrium::fill_equilibrium, frames_to_duration, latch::Latch, + ErrorCallbackArc, + }, traits::StreamTrait, Data, Error, ErrorKind, FrameCount, InputCallbackInfo, InputStreamTimestamp, OutputCallbackInfo, OutputStreamTimestamp, ResultExt, SampleFormat, SampleRate, StreamConfig, @@ -213,8 +216,8 @@ pub struct Stream { // waited on when it is closed. _default_device_monitor: Option, - // Gate that ensures no callbacks fire before the caller receives the `Stream` handle. - stream_ready: Arc, + // Latch that ensures no callbacks fire before the caller receives the `Stream` handle. + latch: Latch, } // SAFETY: Windows Event HANDLEs are safe to send between threads - they are designed for @@ -222,7 +225,7 @@ pub struct Stream { // - JoinHandle<()> is Send // - Sender is Send // - Foundation::HANDLE is Send (Windows synchronization primitive) -// - Arc is Send +// - Latch is Send // See: https://learn.microsoft.com/en-us/windows/win32/api/synchapi/nf-synchapi-createeventa unsafe impl Send for Stream {} @@ -232,7 +235,7 @@ unsafe impl Send for Stream {} // - JoinHandle<()> is Sync // - Sender is Sync (uses internal synchronization) // - Foundation::HANDLE for event objects supports concurrent access -// - Arc is Sync +// - Latch is Sync // The audio thread owns all COM objects, so no cross-thread COM access occurs. unsafe impl Sync for Stream {} @@ -346,17 +349,15 @@ impl Stream { pending_scheduled_event, }; - // `stream_ready` is signalled just before the `Stream` is returned so the worker cannot - // fire any callbacks before the caller has the handle. - let stream_ready = Arc::new(AtomicBool::new(false)); - let stream_ready_worker = stream_ready.clone(); + // The latch is released just before the `Stream` is returned so the worker cannot fire any + // callbacks before the caller has the handle. + let mut latch = Latch::new(); + let waiter = latch.waiter(); let thread = thread::Builder::new() .name("cpal_wasapi_in".to_owned()) .spawn(move || { - while !stream_ready_worker.load(Ordering::Acquire) { - std::thread::park(); - } + waiter.wait(); run_input(run_context, &mut data_callback, &error_callback) }) .map_err(|e| { @@ -366,6 +367,7 @@ impl Stream { ) })?; + latch.add_thread(thread.thread().clone()); let stream = Stream { thread: Some(thread), commands: tx, @@ -373,7 +375,7 @@ impl Stream { period_frames, qpc_frequency: qpc_frequency as u64, _default_device_monitor: default_device_monitor, - stream_ready, + latch, }; Ok(stream) } @@ -417,17 +419,15 @@ impl Stream { pending_scheduled_event, }; - // `stream_ready` is signalled just before the `Stream` is returned so the worker cannot - // fire any callbacks before the caller has the handle. - let stream_ready = Arc::new(AtomicBool::new(false)); - let stream_ready_worker = stream_ready.clone(); + // The latch is released just before the `Stream` is returned so the worker cannot fire any + // callbacks before the caller has the handle. + let mut latch = Latch::new(); + let waiter = latch.waiter(); let thread = thread::Builder::new() .name("cpal_wasapi_out".to_owned()) .spawn(move || { - while !stream_ready_worker.load(Ordering::Acquire) { - std::thread::park(); - } + waiter.wait(); run_output(run_context, &mut data_callback, &error_callback) }) .map_err(|e| { @@ -437,6 +437,7 @@ impl Stream { ) })?; + latch.add_thread(thread.thread().clone()); let stream = Stream { thread: Some(thread), commands: tx, @@ -444,17 +445,14 @@ impl Stream { period_frames, qpc_frequency: qpc_frequency as u64, _default_device_monitor: default_device_monitor, - stream_ready, + latch, }; Ok(stream) } - /// Unblocks the worker thread so it can begin processing audio callbacks. + /// Releases the latch so the worker thread can begin processing audio callbacks. pub(crate) fn signal_ready(&self) { - self.stream_ready.store(true, Ordering::Release); - if let Some(handle) = &self.thread { - handle.thread().unpark(); - } + self.latch.release(); } fn push_command(&self, command: Command) -> Result<(), SendError> { @@ -468,7 +466,7 @@ impl Stream { impl Drop for Stream { fn drop(&mut self) { - // Unblock the worker in case the stream is dropped before signal_ready() was called. + // Release the latch in case the stream is dropped before signal_ready() was called. self.signal_ready(); let _ = self.push_command(Command::Terminate); From aa2694b1ad0cbb5048c04cb9b3b5b0c0c3e9cb74 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Thu, 14 May 2026 20:19:10 +0200 Subject: [PATCH 18/22] fix: derive Debug on latch, clippy lints, iOS/tvOS imports --- src/host/coreaudio/ios/mod.rs | 2 +- src/host/latch.rs | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/host/coreaudio/ios/mod.rs b/src/host/coreaudio/ios/mod.rs index 377074a92..b4f90b6db 100644 --- a/src/host/coreaudio/ios/mod.rs +++ b/src/host/coreaudio/ios/mod.rs @@ -20,7 +20,7 @@ use self::enumerate::{ }; use super::{asbd_from_config, host_time_to_stream_instant}; use crate::{ - host::{frames_to_duration, try_emit_error, ErrorCallbackArc}, + host::{frames_to_duration, latch::Latch, try_emit_error, ErrorCallbackArc}, traits::{DeviceTrait, HostTrait, StreamTrait}, BufferSize, ChannelCount, Data, DeviceDescription, DeviceDescriptionBuilder, DeviceId, Error, ErrorKind, FrameCount, InputCallbackInfo, InputStreamTimestamp, OutputCallbackInfo, diff --git a/src/host/latch.rs b/src/host/latch.rs index bf3b86b70..d63e8bf62 100644 --- a/src/host/latch.rs +++ b/src/host/latch.rs @@ -12,6 +12,7 @@ use std::{ }; /// Signals worker threads that the stream handle has been given to the caller. +#[derive(Debug)] pub(crate) struct Latch { /// `Option` so `Drop` can move it out before unparking, closing the window where a thread /// could wake, see the Arc still alive (flag=false), re-park, then be orphaned. @@ -20,7 +21,7 @@ pub(crate) struct Latch { } /// Held by a worker thread. Parks until the matching [`Latch`] is released. -#[derive(Clone)] +#[derive(Clone, Debug)] pub(crate) struct LatchWaiter(Weak); impl Latch { @@ -88,8 +89,6 @@ impl LatchWaiter { /// Returns `true` if the latch has already been released. #[allow(dead_code)] pub(crate) fn is_released(&self) -> bool { - self.0 - .upgrade() - .map_or(false, |f| f.load(Ordering::Acquire)) + self.0.upgrade().is_some_and(|f| f.load(Ordering::Acquire)) } } From 3f8009542fe66ae67769bab00144851921e9c506 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Thu, 14 May 2026 20:26:31 +0200 Subject: [PATCH 19/22] refactor: clippy lints --- src/host/audioworklet/mod.rs | 1 + src/host/latch.rs | 2 ++ src/host/mod.rs | 1 - src/host/pipewire/stream.rs | 2 +- 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/host/audioworklet/mod.rs b/src/host/audioworklet/mod.rs index 31a5636cf..e3c57352c 100644 --- a/src/host/audioworklet/mod.rs +++ b/src/host/audioworklet/mod.rs @@ -417,6 +417,7 @@ type AudioProcessorCallback = Box; /// WasmAudioProcessor provides an interface for the Javascript code /// running in the AudioWorklet to interact with Rust. #[wasm_bindgen] +#[allow(unused_variables)] pub struct WasmAudioProcessor { #[wasm_bindgen(skip)] interleaved_buffer: Vec, diff --git a/src/host/latch.rs b/src/host/latch.rs index d63e8bf62..01106d559 100644 --- a/src/host/latch.rs +++ b/src/host/latch.rs @@ -43,6 +43,7 @@ impl Latch { } /// Registers a thread to be unparked when [`release`](Self::release) is called. + #[allow(dead_code)] pub(crate) fn add_thread(&mut self, thread: Thread) { self.threads.push(thread); } @@ -73,6 +74,7 @@ impl LatchWaiter { /// Parks the calling thread until the latch is released or dropped without releasing. /// /// Returns `true` if the stream is ready, `false` if the [`Latch`] was dropped before release. + #[allow(dead_code)] pub(crate) fn wait(&self) -> bool { loop { match self.0.upgrade() { diff --git a/src/host/mod.rs b/src/host/mod.rs index 307a904a2..6b67defdd 100644 --- a/src/host/mod.rs +++ b/src/host/mod.rs @@ -91,7 +91,6 @@ pub(crate) mod custom; pub(crate) mod null; #[cfg(any( - target_os = "android", target_vendor = "apple", target_os = "windows", target_os = "linux", diff --git a/src/host/pipewire/stream.rs b/src/host/pipewire/stream.rs index fd70c3c1b..0779bd9f6 100644 --- a/src/host/pipewire/stream.rs +++ b/src/host/pipewire/stream.rs @@ -83,7 +83,7 @@ pub struct Stream { } impl Stream { - pub fn new( + pub(crate) fn new( handle: JoinHandle<()>, controller: pw::channel::Sender, last_quantum: Arc, From 4c31293fd55110eec06bd2923b2bf2cc883295d0 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Fri, 15 May 2026 20:21:30 +0200 Subject: [PATCH 20/22] doc: streams return paused and require play --- UPGRADING.md | 25 ++++++++++++++++++++++++- src/lib.rs | 13 +++++++------ src/traits.rs | 4 ++-- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/UPGRADING.md b/UPGRADING.md index ea10a6507..3ab4beb75 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -20,6 +20,8 @@ This guide covers breaking changes requiring code updates. See [CHANGELOG.md](CH - [ ] If you relied on the default config returning `F32`, pin the sample format explicitly. - [ ] **JACK**: Handle or discard the new `Result` from `Stream::connect_to_system_outputs()` and `Stream::connect_to_system_inputs()`. +- [ ] **CoreAudio, JACK**: Add an explicit `stream.play()` call after `build_*_stream()` if you + were relying these backends to auto-start streams. ## 1. Unified `Error` and `ErrorKind` type @@ -237,7 +239,28 @@ explicitly, rename it to `realtime-dbus`. If you relied on the opt-out behavior, **Why:** Real-time scheduling is the correct default for audio applications. The previous opt-in made it easy to accidentally ship without it. -## 7. `wasm32-unknown-emscripten` target removed +## 7. Streams are returned paused on every backend + +**What changed:** `build_input_stream` and `build_output_stream` now return a paused `Stream` on +every backend. Previously, CoreAudio and JACK started the stream automatically. + +```rust +// Before (v0.17): on CoreAudio/JACK the stream was already running +let stream = device.build_output_stream(config, data_fn, err_fn, None)?; + +// After (v0.18): every backend requires play() +let stream = device.build_output_stream(config, data_fn, err_fn, None)?; +stream.play()?; +``` + +**Impact:** If you were targeting CoreAudio or JACK and never called `play()`, your callback will +never fire after upgrading. Add the `play()` call. + +**Why:** Auto-starting before the caller has the `Stream` handle creates a window where data and +error callbacks can fire before the application can pause, stop, or drop the stream. Other hosts +already required `play()`. The behavior is now uniform. + +## 8. `wasm32-unknown-emscripten` target removed **What changed:** The `emscripten` audio host and the `wasm32-unknown-emscripten` build target are no longer supported. diff --git a/src/lib.rs b/src/lib.rs index 37d0adbd8..9aa5d58b2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -78,10 +78,11 @@ //! ); //! ``` //! -//! While the stream is running, the selected audio device will periodically call the data callback -//! that was passed to the function. For input streams, the callback receives `&`[`Data`] containing -//! captured audio samples. For output streams, the callback receives `&mut`[`Data`] to be filled -//! with audio samples for playback. +//! Streams are returned in a paused state. Once the stream has been started with +//! [`Stream::play`](traits::StreamTrait::play), the selected audio device will periodically call +//! the data callback that was passed to the function. For input streams, the callback receives +//! `&`[`Data`] containing captured audio samples. For output streams, the callback receives +//! `&mut`[`Data`] to be filled with audio samples for playback. //! //! > **Note**: Creating and running a stream will *not* block the thread. On modern platforms, the //! > given callback is called by a dedicated, high-priority thread responsible for delivering @@ -117,8 +118,8 @@ //! } //! ``` //! -//! Not all platforms automatically run the stream upon creation. To ensure the stream has started, -//! we can use [`Stream::play`](traits::StreamTrait::play). +//! Streams are always returned in a paused state, so we must call +//! [`Stream::play`](traits::StreamTrait::play) to start running the data callback. //! //! ```no_run //! # use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; diff --git a/src/traits.rs b/src/traits.rs index a8d5cf4aa..32eacc079 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -409,8 +409,8 @@ pub trait DeviceTrait { pub trait StreamTrait { /// Run the stream. /// - /// Note: Not all platforms automatically run the stream upon creation, so it is important to - /// call `play` after creation if it is expected that the stream should run immediately. + /// Streams returned by `build_*_stream` are always paused, so `play` must be called before the + /// data callback will fire. /// /// # Errors /// From abc43a3854909eea1cf996ee8e9632d5f45c7f38 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Fri, 15 May 2026 20:22:16 +0200 Subject: [PATCH 21/22] refactor: make Starting the initial StreamState --- src/host/asio/stream.rs | 26 +++++++++++++++----------- src/host/jack/stream.rs | 14 +++++++------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/src/host/asio/stream.rs b/src/host/asio/stream.rs index 014be96c3..72005381f 100644 --- a/src/host/asio/stream.rs +++ b/src/host/asio/stream.rs @@ -12,7 +12,11 @@ use std::{ use self::num_traits::{FromPrimitive, PrimInt}; use super::Device; use crate::{ - host::{com, error_emit::try_emit_error, frames_to_duration}, + host::{ + com, + error_emit::{emit_error, try_emit_error}, + frames_to_duration, + }, BufferSize, Data, Error, ErrorKind, FrameCount, InputCallbackInfo, InputStreamTimestamp, OutputCallbackInfo, OutputStreamTimestamp, SampleFormat, SampleRate, StreamConfig, StreamInstant, I24, @@ -59,7 +63,7 @@ const ASIO_EVENT_DEBOUNCE: Duration = Duration::from_millis(500); #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] enum StreamState { #[default] - Idle = 0, + Starting = 0, Paused = 1, Playing = 2, } @@ -69,7 +73,7 @@ impl StreamState { match atom.load(Ordering::Acquire) { 1 => StreamState::Paused, 2 => StreamState::Playing, - _ => StreamState::Idle, + _ => StreamState::Starting, } } @@ -183,7 +187,7 @@ impl Device { .unwrap_or(0), )); - let state = Arc::new(AtomicU8::new(StreamState::Idle as u8)); + let state = Arc::new(AtomicU8::new(StreamState::Starting as u8)); let driver_event_callback_id = self .add_event_callback( &driver, @@ -513,7 +517,7 @@ impl Device { .unwrap_or(0), )); - let state = Arc::new(AtomicU8::new(StreamState::Idle as u8)); + let state = Arc::new(AtomicU8::new(StreamState::Starting as u8)); let driver_event_callback_id = self .add_event_callback( &driver, @@ -966,7 +970,7 @@ impl Device { Err(mpsc::RecvTimeoutError::Timeout) => { // Grace period elapsed with no new events: now deliver. if let Some(err) = pending.take() { - error_cb_for_timer.lock().unwrap_or_else(|e| e.into_inner())(err); + emit_error(&error_cb_for_timer, err); } } Err(mpsc::RecvTimeoutError::Disconnected) => return, @@ -995,9 +999,9 @@ impl Device { ) } sys::AsioMessageSelectors::kAsioResetRequest => { - // Guard on Idle: some USB ASIO drivers (ASIO4ALL, Focusrite, etc.) fire - // spurious reset/resync requests during driver.start(). - if StreamState::load(&state) != StreamState::Idle { + // Guard on Starting: some USB ASIO drivers (ASIO4ALL, Focusrite, etc.) + // fire spurious reset/resync requests during driver.start(). + if StreamState::load(&state) != StreamState::Starting { let _ = timer_tx.send(Error::with_message( ErrorKind::StreamInvalidated, "ASIO driver requested stream reset", @@ -1009,7 +1013,7 @@ impl Device { // Per the ASIO spec (and matching JUCE's behavior), kAsioResyncRequest // means the driver needs a full stop/reinit/start. It is *not* a simple // xrun notification. - if StreamState::load(&state) != StreamState::Idle { + if StreamState::load(&state) != StreamState::Starting { let _ = timer_tx.send(Error::with_message( ErrorKind::StreamInvalidated, "ASIO driver requested stream resynchronization", @@ -1061,7 +1065,7 @@ impl Device { true } }; - if should_notify && StreamState::load(&state) != StreamState::Idle { + if should_notify && StreamState::load(&state) != StreamState::Starting { let _ = timer_tx.send(Error::with_message( ErrorKind::StreamInvalidated, format!("ASIO driver changed sample rate to {new_rate} Hz"), diff --git a/src/host/jack/stream.rs b/src/host/jack/stream.rs index cfcbc40ec..44294a1e5 100644 --- a/src/host/jack/stream.rs +++ b/src/host/jack/stream.rs @@ -15,7 +15,7 @@ use crate::{ #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] enum StreamState { #[default] - Initializing = 0, + Starting = 0, Paused = 1, Playing = 2, } @@ -25,7 +25,7 @@ impl StreamState { match atom.load(order) { 1 => Self::Paused, 2 => Self::Playing, - _ => Self::Initializing, + _ => Self::Starting, } } @@ -69,7 +69,7 @@ impl Stream { ports.push(port); } - let state = Arc::new(AtomicU8::new(StreamState::Initializing as u8)); + let state = Arc::new(AtomicU8::new(StreamState::Starting as u8)); let error_callback_ptr: ErrorCallbackArc = Arc::new(Mutex::new(error_callback)); let input_process_handler = LocalProcessHandler::new( @@ -125,7 +125,7 @@ impl Stream { ports.push(port); } - let state = Arc::new(AtomicU8::new(StreamState::Initializing as u8)); + let state = Arc::new(AtomicU8::new(StreamState::Starting as u8)); let error_callback_ptr: ErrorCallbackArc = Arc::new(Mutex::new(error_callback)); let output_process_handler = LocalProcessHandler::new( @@ -543,7 +543,7 @@ impl JackNotificationHandler { impl jack::NotificationHandler for JackNotificationHandler { unsafe fn shutdown(&mut self, _status: jack::ClientStatus, reason: &str) { - if StreamState::load(&self.state, Ordering::Acquire) == StreamState::Initializing { + if StreamState::load(&self.state, Ordering::Acquire) == StreamState::Starting { return; } emit_error( @@ -560,7 +560,7 @@ impl jack::NotificationHandler for JackNotificationHandler { // One of these notifications is sent every time a client is started. return jack::Control::Continue; } - if StreamState::load(&self.state, Ordering::Acquire) != StreamState::Initializing { + if StreamState::load(&self.state, Ordering::Acquire) != StreamState::Starting { emit_error( &self.error_callback_ptr, Error::with_message( @@ -573,7 +573,7 @@ impl jack::NotificationHandler for JackNotificationHandler { } fn xrun(&mut self, _: &jack::Client) -> jack::Control { - if StreamState::load(&self.state, Ordering::Acquire) != StreamState::Initializing { + if StreamState::load(&self.state, Ordering::Acquire) != StreamState::Starting { let _ = try_emit_error( &self.error_callback_ptr, Error::with_message(ErrorKind::Xrun, "JACK xrun detected"), From 0550703b9c5ac08fc5ace5f9a56e9ac20e551271 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Fri, 15 May 2026 20:22:49 +0200 Subject: [PATCH 22/22] refactor: add OS-specific cfgs to Latch methods --- src/host/latch.rs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/host/latch.rs b/src/host/latch.rs index 01106d559..6d979640d 100644 --- a/src/host/latch.rs +++ b/src/host/latch.rs @@ -43,7 +43,14 @@ impl Latch { } /// Registers a thread to be unparked when [`release`](Self::release) is called. - #[allow(dead_code)] + #[cfg(any( + target_os = "macos", + target_os = "windows", + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + ))] pub(crate) fn add_thread(&mut self, thread: Thread) { self.threads.push(thread); } @@ -74,7 +81,14 @@ impl LatchWaiter { /// Parks the calling thread until the latch is released or dropped without releasing. /// /// Returns `true` if the stream is ready, `false` if the [`Latch`] was dropped before release. - #[allow(dead_code)] + #[cfg(any( + target_os = "macos", + target_os = "windows", + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + ))] pub(crate) fn wait(&self) -> bool { loop { match self.0.upgrade() { @@ -89,7 +103,7 @@ impl LatchWaiter { } /// Returns `true` if the latch has already been released. - #[allow(dead_code)] + #[cfg(all(target_vendor = "apple", not(target_os = "macos")))] pub(crate) fn is_released(&self) -> bool { self.0.upgrade().is_some_and(|f| f.load(Ordering::Acquire)) }