From 56303a3372e1007c3d2a587ebcc42939e2794901 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Wed, 7 Oct 2026 14:56:19 +0000 Subject: [PATCH] audio: Recover from notification output stream failures (#65158) Closes https://github.com/zed-industries/zed/issues/64962 Update CPAL and rodio to remove the uninterruptible ALSA write retry loop. Discard failed output streams so subsequent notifications can reopen the device. Preserve recoverable underruns, cancel stale error handlers on teardown, and keep existing output buffer defaults. Release Notes: - Fixed audio thread failing to properly stop on errors --- Cargo.lock | 58 ++++----- Cargo.toml | 5 +- crates/audio/Cargo.toml | 4 + crates/audio/src/audio_pipeline.rs | 122 ++++++++++++++++-- .../src/livekit_client/playback.rs | 4 +- crates/livekit_client/src/record.rs | 4 +- 6 files changed, 149 insertions(+), 48 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f03be68c276..4d23f89e9dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -665,9 +665,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "alsa" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c88dbbce13b232b26250e1e2e6ac18b6a891a646b8148285036ebce260ac5c3" +checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" dependencies = [ "alsa-sys", "bitflags 2.13.1", @@ -677,9 +677,9 @@ dependencies = [ [[package]] name = "alsa-sys" -version = "0.3.1" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04" dependencies = [ "libc", "pkg-config", @@ -1351,6 +1351,7 @@ dependencies = [ "collections", "cpal", "crossbeam-queue", + "futures 0.3.34", "gpui", "libwebrtc", "log", @@ -3929,11 +3930,11 @@ dependencies = [ [[package]] name = "coreaudio-rs" -version = "0.13.0" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17" +checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.13.1", "libc", "objc2-audio-toolbox", "objc2-core-audio", @@ -3976,17 +3977,17 @@ dependencies = [ [[package]] name = "cpal" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b1f9c7312f19fc2fa12fd7acaf38de54e8320ba10d1a02dcbe21038def51ccb" +version = "0.18.2" +source = "git+https://github.com/RustAudio/cpal?rev=e1612d5d98152f8dc2a62e1b51ef7cbf4f7f26b7#e1612d5d98152f8dc2a62e1b51ef7cbf4f7f26b7" dependencies = [ "alsa", - "coreaudio-rs 0.13.0", + "block2 0.6.2", + "coreaudio-rs 0.14.2", "dasp_sample", - "jni 0.21.1", + "jni 0.22.4", "js-sys", "libc", - "mach2 0.5.0", + "mach2", "ndk", "ndk-context", "num-derive", @@ -3998,10 +3999,9 @@ dependencies = [ "objc2-core-audio-types", "objc2-core-foundation", "objc2-foundation 0.3.2", - "wasm-bindgen", - "wasm-bindgen-futures", "web-sys", "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] @@ -4185,7 +4185,7 @@ checksum = "3748e166981fa3ba9dc54ff950ad0ed5ca4aab4b73eae21d9f539659fe806e28" dependencies = [ "cfg-if", "libc", - "mach2 0.6.0", + "mach2", ] [[package]] @@ -4197,7 +4197,7 @@ dependencies = [ "cfg-if", "crash-context", "libc", - "mach2 0.6.0", + "mach2", "parking_lot", ] @@ -4209,7 +4209,7 @@ dependencies = [ "crash-handler", "libc", "log", - "mach2 0.6.0", + "mach2", "minidumper", "parking_lot", "serde", @@ -7497,7 +7497,7 @@ dependencies = [ "image", "libc", "log", - "mach2 0.6.0", + "mach2", "metal", "objc2 0.6.3", "objc2-foundation 0.3.2", @@ -10537,15 +10537,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "mach2" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" -dependencies = [ - "libc", -] - [[package]] name = "mach2" version = "0.6.0" @@ -10989,7 +10980,7 @@ dependencies = [ "goblin", "libc", "log", - "mach2 0.6.0", + "mach2", "memmap2", "minidump-common", "process-backend", @@ -11741,6 +11732,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" dependencies = [ + "bitflags 2.13.1", "objc2 0.6.3", "objc2-foundation 0.3.2", ] @@ -15311,8 +15303,8 @@ dependencies = [ [[package]] name = "rodio" -version = "0.21.1" -source = "git+https://github.com/RustAudio/rodio?rev=e50e726ddd0292f6ef9de0dda6b90af4ed1fb66a#e50e726ddd0292f6ef9de0dda6b90af4ed1fb66a" +version = "0.22.2" +source = "git+https://github.com/RustAudio/rodio?rev=3ee9745862279ce1d18db593155016e7ce024aeb#3ee9745862279ce1d18db593155016e7ce024aeb" dependencies = [ "cpal", "dasp_sample", @@ -19474,7 +19466,7 @@ dependencies = [ "itertools 0.14.0", "libc", "log", - "mach2 0.6.0", + "mach2", "nix", "path", "percent-encoding", @@ -20025,7 +20017,7 @@ dependencies = [ "futures 0.3.34", "libc", "log", - "mach2 0.6.0", + "mach2", "memfd", "object 0.39.1", "once_cell", diff --git a/Cargo.toml b/Cargo.toml index 6ce47ccbc0b..1239adbbacd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -452,7 +452,7 @@ remote_connection = { path = "crates/remote_connection" } remote_server = { path = "crates/remote_server" } repl = { path = "crates/repl" } reqwest_client = { path = "crates/reqwest_client" } -rodio = { git = "https://github.com/RustAudio/rodio", rev = "e50e726ddd0292f6ef9de0dda6b90af4ed1fb66a", default-features = false, features = ["wav", "playback", "wav_output", "recording"] } +rodio = { git = "https://github.com/RustAudio/rodio", rev = "3ee9745862279ce1d18db593155016e7ce024aeb", default-features = false, features = ["wav", "playback", "wav_output", "recording"] } rope = { path = "crates/rope" } rpc = { path = "crates/rpc" } sandbox = { path = "crates/sandbox" } @@ -604,7 +604,7 @@ core-services = "1" core-text = "21" core-video = { version = "0.5", default-features = false, features = ["link"] } coreaudio-rs = { version = "0.12", default-features = false } -cpal = "0.17" +cpal = { git = "https://github.com/RustAudio/cpal", rev = "e1612d5d98152f8dc2a62e1b51ef7cbf4f7f26b7" } crash-handler = "0.8" criterion = { version = "0.8.2", features = ["html_reports"] } perf-event = { package = "perf-event2", version = "0.7.4" } @@ -981,6 +981,7 @@ features = [ ] [patch.crates-io] +cpal = { git = "https://github.com/RustAudio/cpal", rev = "e1612d5d98152f8dc2a62e1b51ef7cbf4f7f26b7" } # Unify `tree_sitter_language::LanguageFn` between crates.io grammar crates and # the git tree-sitter above; two instances of the crate would make grammar # constants type-incompatible with `tree_sitter::Language`. diff --git a/crates/audio/Cargo.toml b/crates/audio/Cargo.toml index d3f59e293c1..9e10f135f6a 100644 --- a/crates/audio/Cargo.toml +++ b/crates/audio/Cargo.toml @@ -17,6 +17,7 @@ anyhow.workspace = true collections.workspace = true cpal.workspace = true crossbeam-queue.workspace = true +futures.workspace = true gpui.workspace = true log.workspace = true parking_lot.workspace = true @@ -25,5 +26,8 @@ settings.workspace = true thiserror.workspace = true util.workspace = true +[dev-dependencies] +gpui = { workspace = true, features = ["test-support"] } + [target.'cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))'.dependencies] libwebrtc.workspace = true diff --git a/crates/audio/src/audio_pipeline.rs b/crates/audio/src/audio_pipeline.rs index e09f4474af6..a6d33492a42 100644 --- a/crates/audio/src/audio_pipeline.rs +++ b/crates/audio/src/audio_pipeline.rs @@ -1,10 +1,11 @@ use anyhow::{Context as _, Result}; use collections::HashMap; use cpal::{ - DeviceDescription, DeviceId, default_host, + DeviceDescription, DeviceId, Error, ErrorKind, default_host, traits::{DeviceTrait, HostTrait}, }; -use gpui::{App, AsyncApp, BorrowAppContext, Global}; +use futures::channel::oneshot; +use gpui::{App, AsyncApp, BorrowAppContext, Global, Task}; pub(super) use cpal::Sample; @@ -47,6 +48,7 @@ pub fn ensure_devices_initialized(cx: &mut App) { #[derive(Default)] pub struct Audio { output: Option<(MixerDeviceSink, Mixer)>, + output_error_task: Option>, pub echo_canceller: EchoCanceller, source_cache: HashMap>>>>, } @@ -54,16 +56,25 @@ pub struct Audio { impl Global for Audio {} impl Audio { - fn ensure_output_exists(&mut self, output_audio_device: Option) -> Result<&Mixer> { + fn ensure_output_exists( + &mut self, + output_audio_device: Option, + cx: &mut App, + ) -> Result<&Mixer> { #[cfg(debug_assertions)] log::warn!( "Audio does not sound correct without optimizations. Use a release build to debug audio issues" ); if self.output.is_none() { - let (output_handle, output_mixer) = - open_output_stream(output_audio_device, self.echo_canceller.clone())?; - self.output = Some((output_handle, output_mixer)); + let (error_callback, error_task) = output_error_handler(cx); + let output = open_output_stream( + output_audio_device, + self.echo_canceller.clone(), + error_callback, + )?; + self.output = Some(output); + self.output_error_task = Some(error_task); } Ok(self @@ -78,7 +89,7 @@ impl Audio { cx.update_default_global(|this: &mut Self, cx| { let source = this.sound_source(sound, cx).log_err()?; let output_mixer = this - .ensure_output_exists(output_audio_device) + .ensure_output_exists(output_audio_device, cx) .context("Could not get output mixer") .log_err()?; @@ -88,8 +99,9 @@ impl Audio { } pub fn end_call(cx: &mut App) { - cx.update_default_global(|this: &mut Self, _cx| { - this.output.take(); + cx.update_default_global(|audio: &mut Self, _cx| { + audio.output_error_task.take(); + audio.output.take(); }); } @@ -170,6 +182,7 @@ pub fn resolve_device(device_id: Option<&DeviceId>, input: bool) -> anyhow::Resu pub fn open_test_output(device_id: Option) -> anyhow::Result { let device = resolve_device(device_id.as_ref(), false)?; DeviceSinkBuilder::from_device(device)? + .with_buffer_size(cpal::BufferSize::Default) .open_stream() .context("Could not open output stream") } @@ -177,9 +190,12 @@ pub fn open_test_output(device_id: Option) -> anyhow::Result, mut echo_canceller: EchoCanceller, + error_callback: impl FnMut(Error) + Send + 'static, ) -> anyhow::Result<(MixerDeviceSink, Mixer)> { let device = resolve_device(device_id.as_ref(), false)?; let mut output_handle = DeviceSinkBuilder::from_device(device)? + .with_buffer_size(cpal::BufferSize::Default) + .with_error_callback(error_callback) .open_stream() .context("Could not open output stream")?; output_handle.log_on_drop(false); @@ -241,3 +257,91 @@ fn get_available_audio_devices() -> Vec { pub struct AvailableAudioDevices(pub Vec); impl Global for AvailableAudioDevices {} + +fn output_error_handler(cx: &mut App) -> (impl FnMut(Error) + Send + 'static + use<>, Task<()>) { + let (sender, receiver) = oneshot::channel(); + let mut sender = Some(sender); + let callback = move |error: Error| { + if matches!( + error.kind(), + ErrorKind::Xrun | ErrorKind::DeviceChanged | ErrorKind::RealtimeDenied + ) { + return; + } + if let Some(sender) = sender.take() { + sender.send(error).ok(); + } + }; + let task = cx.spawn(async move |cx| { + if let Ok(error) = receiver.await { + log::error!("Audio output stream failed: {error}"); + cx.update(Audio::end_call); + } + }); + (callback, task) +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::TestAppContext; + + #[gpui::test] + fn test_output_error_handler(cx: &mut TestAppContext) { + for error_kind in [ + ErrorKind::DeviceNotAvailable, + ErrorKind::StreamInvalidated, + ErrorKind::BackendError, + ] { + let mut callback = cx.update(install_output_error_handler); + for recoverable_kind in [ + ErrorKind::Xrun, + ErrorKind::DeviceChanged, + ErrorKind::RealtimeDenied, + ] { + callback(Error::new(recoverable_kind)); + cx.run_until_parked(); + cx.update(|cx| assert!(cx.global::