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
This commit is contained in:
Kirill Bulatov 2026-10-07 14:56:19 +00:00 • committed by GitHub
parent 976ab33fb4
commit 56303a3372
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 149 additions and 48 deletions

58
Cargo.lock generated
View file

@ -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",

View file

@ -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`.

View file

@ -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

View file

@ -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<Task<()>>,
pub echo_canceller: EchoCanceller,
source_cache: HashMap<Sound, Buffered<Decoder<Cursor<Vec<u8>>>>>,
}
@ -54,16 +56,25 @@ pub struct Audio {
impl Global for Audio {}
impl Audio {
fn ensure_output_exists(&mut self, output_audio_device: Option<DeviceId>) -> Result<&Mixer> {
fn ensure_output_exists(
&mut self,
output_audio_device: Option<DeviceId>,
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<DeviceId>) -> anyhow::Result<MixerDeviceSink> {
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<DeviceId>) -> anyhow::Result<MixerDevi
pub fn open_output_stream(
device_id: Option<DeviceId>,
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<AudioDeviceInfo> {
pub struct AvailableAudioDevices(pub Vec<AudioDeviceInfo>);
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::<Audio>().output_error_task.is_some()));
}
callback(Error::new(error_kind));
callback(Error::new(error_kind));
cx.run_until_parked();
cx.update(|cx| assert!(cx.global::<Audio>().output_error_task.is_none()));
}
}
#[gpui::test]
fn test_output_error_handler_cancellation(cx: &mut TestAppContext) {
for error_before_close in [false, true] {
let mut old_callback = cx.update(install_output_error_handler);
if error_before_close {
old_callback(Error::new(ErrorKind::DeviceNotAvailable));
}
cx.update(Audio::end_call);
let mut callback = cx.update(install_output_error_handler);
old_callback(Error::new(ErrorKind::DeviceNotAvailable));
cx.run_until_parked();
cx.update(|cx| assert!(cx.global::<Audio>().output_error_task.is_some()));
callback(Error::new(ErrorKind::StreamInvalidated));
cx.run_until_parked();
cx.update(|cx| assert!(cx.global::<Audio>().output_error_task.is_none()));
}
let callback = cx.update(install_output_error_handler);
drop(callback);
cx.run_until_parked();
cx.update(|cx| assert!(cx.global::<Audio>().output_error_task.is_some()));
cx.update(Audio::end_call);
}
fn install_output_error_handler(cx: &mut App) -> impl FnMut(Error) + Send + 'static + use<> {
let (callback, task) = output_error_handler(cx);
cx.update_default_global(|audio: &mut Audio, _cx| {
audio.output_error_task = Some(task);
});
callback
}
}

View file

@ -248,7 +248,7 @@ impl AudioStack {
executor
.spawn_with_priority(Priority::RealtimeAudio, async move {
let output_stream = output_device.build_output_stream(
&output_config.config(),
output_config.config(),
{
move |mut data, _info| {
while data.len() > 0 {
@ -340,7 +340,7 @@ impl AudioStack {
let stream = device
.build_input_stream_raw(
&config.config(),
config.config(),
config.sample_format(),
move |data, _: &_| {
let captured_at = Instant::now();

View file

@ -30,7 +30,7 @@ impl CaptureInput {
log::info!("Using microphone: {}", name);
let samples = Arc::new(Mutex::new(Vec::new()));
let stream = start_capture(device, config.clone(), samples.clone())?;
let stream = start_capture(device, config, samples.clone())?;
Ok(Self {
name,
@ -58,7 +58,7 @@ fn start_capture(
) -> Result<cpal::Stream> {
let stream = device
.build_input_stream_raw(
&config.config(),
config.config(),
config.sample_format(),
move |data, _: &_| {
let data = crate::get_sample_data(config.sample_format(), data).log_err();