From 4ef855d05d2bd665cb7ee3b757425eee39d051ca Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:49:45 +0300 Subject: [PATCH] fix(linux): prevent SIGABRT crash when setting volume to 0% ChannelVolumes::default() has 0 channels and is rejected as invalid by PulseAudio (pa_cvolume_valid returns false). When setting volume to 0%, the previous code skipped channel volume initialization, passing the empty structure to set_sink_volume_by_name / set_source_volume_by_name. PulseAudio returned a NULL operation pointer, triggering an assertion panic in libpulse-binding and SIGABRT. Now channel_volumes_for guarantees valid ChannelVolumes with CHANNELS_MAX channels set to Volume::MUTED whenever scalar <= 0 or is NaN, and device names are validated against empty strings. --- src-tauri/src/audio/volume/linux.rs | 85 +++++++++++++++++++++++++++-- 1 file changed, 81 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/audio/volume/linux.rs b/src-tauri/src/audio/volume/linux.rs index 7cd6a16..d4ec5ff 100644 --- a/src-tauri/src/audio/volume/linux.rs +++ b/src-tauri/src/audio/volume/linux.rs @@ -77,8 +77,16 @@ fn device_volume_from(volume: &ChannelVolumes, mute: bool) -> DeviceVolume { // "default"/"pipewire"/"sysdefault" are route aliases, not PulseAudio sink // names; resolve them to the server's actual default sink/source name. fn resolve_device(intro: &Introspector, kind: DeviceKind, name: &str) -> Option { + let name = name.trim(); + if name.is_empty() { + return None; + } if kind == DeviceKind::Input { if let Some(sink) = name.strip_prefix("monitor:") { + let sink = sink.trim(); + if sink.is_empty() { + return None; + } return Some(format!("{sink}.monitor")); } } @@ -91,7 +99,8 @@ fn resolve_device(intro: &Introspector, kind: DeviceKind, name: &str) -> Option< DeviceKind::Output => info.default_sink_name.clone(), DeviceKind::Input => info.default_source_name.clone(), }; - *out_cb.lock().unwrap() = n.map(|c| c.into_owned()); + *out_cb.lock().unwrap() = + n.map(|c| c.into_owned()).filter(|s| !s.trim().is_empty()); }); let _ = wait_done(&op); let result = out.lock().unwrap().take(); @@ -134,15 +143,25 @@ pub fn device_volume(kind: DeviceKind, name: &str) -> Option { result } -pub fn set_device_volume(kind: DeviceKind, name: &str, scalar: f32) -> bool { - let mute = scalar <= 0.0; +fn channel_volumes_for(scalar: f32) -> ChannelVolumes { let mut volumes = ChannelVolumes::default(); - if !mute { + if scalar <= 0.0 || scalar.is_nan() { + volumes.mute(ChannelVolumes::CHANNELS_MAX); + } else { let s = scalar.clamp(0.0, 1.0); // Inverse of the read-side cbrt: request the device volume `s`. let v: Volume = VolumeLinear((s * s * s) as f64).into(); volumes.set(ChannelVolumes::CHANNELS_MAX, v); } + volumes +} + +pub fn set_device_volume(kind: DeviceKind, name: &str, scalar: f32) -> bool { + let mute = scalar.is_nan() || scalar <= 0.0; + let volumes = channel_volumes_for(scalar); + if !volumes.is_valid() { + return false; + } with_connection(move |context| { let mut intro = context.introspect(); let Some(real) = resolve_device(&intro, kind, name) else { @@ -267,3 +286,61 @@ fn spawn_watcher() -> Option> { .ok()?; Some(stop) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn channel_volumes_for_zero_is_muted_and_valid() { + // ChannelVolumes::default() has channels = 0 and is invalid; passing it + // to PulseAudio causes a NULL operation pointer and SIGABRT (issue #53). + assert_eq!(ChannelVolumes::default().len(), 0); + assert!(!ChannelVolumes::default().is_valid()); + + let volumes = channel_volumes_for(0.0); + assert!(volumes.is_valid()); + assert_eq!(volumes.len(), ChannelVolumes::CHANNELS_MAX); + assert!(volumes.is_muted()); + } + + #[test] + fn channel_volumes_for_negative_and_nan_are_muted_and_valid() { + let neg = channel_volumes_for(-0.5); + assert!(neg.is_valid()); + assert!(neg.is_muted()); + + let nan = channel_volumes_for(f32::NAN); + assert!(nan.is_valid()); + assert!(nan.is_muted()); + } + + #[test] + fn channel_volumes_for_positive_scalar() { + let full = channel_volumes_for(1.0); + assert!(full.is_valid()); + assert_eq!(full.len(), ChannelVolumes::CHANNELS_MAX); + assert!(full.is_norm()); + + let half = channel_volumes_for(0.5); + assert!(half.is_valid()); + assert_eq!(half.len(), ChannelVolumes::CHANNELS_MAX); + assert!(!half.is_muted()); + } + + #[test] + fn device_volume_from_muted() { + let volumes = channel_volumes_for(0.0); + let dev = device_volume_from(&volumes, true); + assert_eq!(dev.scalar, 0.0); + assert_eq!(dev.db, Some(MUTED_DB)); + } + + #[test] + fn device_volume_from_unmuted() { + let volumes = channel_volumes_for(1.0); + let dev = device_volume_from(&volumes, false); + assert!((dev.scalar - 1.0).abs() < 1e-4); + assert_eq!(dev.db, Some(0.0)); + } +}