fix(audio): resume the suspended sink after exclusive access (#263 leak)

Exclusive-mode device acquisition suspends the default PipeWire sink
(pactl suspend-sink @DEFAULT_SINK@ 1) on the EBUSY-retry and CPAL-exclusive
paths, but never resumed it — leaving the rest of the system on a suspended
sink after exclusive playback.

Add suspend_default_sink_for_exclusive() (resolves + records the suspended
sink name) and resume_suspended_sink() (resumes that exact name; PipeWire sink
names are deterministic, so resume-by-name survives a vacate/re-create). The
player resumes on the pause-suspend teardown — after the exclusive stream is
actually dropped, never while the device is still held. Self-gating: a no-op
unless QBZ suspended a sink, so it is safe to call on every stop.

Does not touch the bit-perfect path. Verified no regression on hardware:
exclusive mode plays through the DAC (LED-confirmed) and system audio uses the
onboard sink (not stuck suspended). The suspend/resume paths themselves
(EBUSY race / CPAL-exclusive fallback) are rare and were not exercised in that
test; the fix is correct-by-construction and cannot affect bit-perfect.

cc
This commit is contained in:
vicrodh committed 2026-05-31 10:58:16 -06:00
1 parent b139392b9b
commit 64fb77ade1
2 files changed
+72 -19

No files matched your search

+67 -19
View File
@@ -20,6 +20,67 @@ use rodio::{
};
use std::collections::HashMap;
use std::fs;
use std::sync::Mutex;
/// The PipeWire sink QBZ suspended to take a device exclusively (ALSA-direct
/// EBUSY retry / CPAL-exclusive). Recorded by *resolved name* so
/// `resume_suspended_sink` wakes the exact sink that was suspended — PipeWire
/// sink names are deterministic, so resume-by-name is reliable even if the sink
/// was vacated and re-created while QBZ held the device. Issue #263: QBZ
/// suspended the default sink but never resumed it, leaving the rest of the
/// system stuck on a suspended sink after exclusive playback.
static SUSPENDED_SINK: Mutex<Option<String>> = Mutex::new(None);
/// Suspend the current default PipeWire sink so an exclusive ALSA device open
/// can grab the hardware, recording the resolved sink name for later resume.
/// Falls back to the `@DEFAULT_SINK@` alias if the name can't be resolved.
fn suspend_default_sink_for_exclusive() {
let resolved = std::process::Command::new("pactl")
.args(["get-default-sink"])
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let target = resolved.unwrap_or_else(|| "@DEFAULT_SINK@".to_string());
match std::process::Command::new("pactl")
.args(["suspend-sink", &target, "1"])
.output()
{
Ok(output) if output.status.success() => {
log::info!(
"[ALSA Backend] Suspended PipeWire sink '{}' for exclusive access",
target
);
if let Ok(mut guard) = SUSPENDED_SINK.lock() {
*guard = Some(target);
}
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
log::warn!("[ALSA Backend] Failed to suspend PipeWire sink: {}", stderr);
}
Err(e) => log::warn!("[ALSA Backend] Error suspending PipeWire sink: {}", e),
}
}
/// Resume the PipeWire sink QBZ suspended for exclusive access (issue #263 leak
/// fix). No-op if QBZ did not suspend one — so it is safe to call on every
/// stop/teardown. Call it once the exclusive device has actually been released
/// so the rest of the system can use the sink again.
pub fn resume_suspended_sink() {
let target = match SUSPENDED_SINK.lock() {
Ok(mut guard) => guard.take(),
Err(_) => None,
};
if let Some(sink) = target {
let _ = std::process::Command::new("pactl")
.args(["suspend-sink", &sink, "0"])
.output();
log::info!("[ALSA Backend] Resumed PipeWire sink '{}'", sink);
}
}
/// Common audio sample rates to check for device support
const COMMON_SAMPLE_RATES: &[u32] = &[
@@ -725,10 +786,9 @@ impl AlsaBackend {
// Retry with progressive backoff before giving up.
log::info!("[ALSA Backend] Device busy — retrying with backoff");
// Try suspending PipeWire once (covers PipeWire-held case)
let _ = std::process::Command::new("pactl")
.args(["suspend-sink", "@DEFAULT_SINK@", "1"])
.output();
// Try suspending PipeWire once (covers PipeWire-held case).
// Records the sink so it is resumed on stop/teardown (#263).
suspend_default_sink_for_exclusive();
let retry_delays_ms = [50, 100, 200, 400, 800];
for (i, delay_ms) in retry_delays_ms.iter().enumerate() {
@@ -821,9 +881,7 @@ impl AlsaBackend {
if matches!(error, super::backend::AlsaDirectError::DeviceBusy(_)) {
log::info!("[ALSA Backend] plughw device busy — retrying with backoff");
let _ = std::process::Command::new("pactl")
.args(["suspend-sink", "@DEFAULT_SINK@", "1"])
.output();
suspend_default_sink_for_exclusive();
let retry_delays_ms = [50, 100, 200, 400, 800];
for (i, delay_ms) in retry_delays_ms.iter().enumerate() {
@@ -1143,18 +1201,8 @@ impl AudioBackend for AlsaBackend {
log::info!(
"[ALSA Backend] Exclusive mode: suspending PipeWire sinks before CPAL stream"
);
if let Ok(output) = std::process::Command::new("pactl")
.args(["suspend-sink", "@DEFAULT_SINK@", "1"])
.output()
{
if output.status.success() {
log::info!("[ALSA Backend] PipeWire sink suspended");
std::thread::sleep(std::time::Duration::from_millis(200));
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
log::warn!("[ALSA Backend] Failed to suspend PipeWire sink: {}", stderr);
}
}
suspend_default_sink_for_exclusive();
std::thread::sleep(std::time::Duration::from_millis(200));
}
// Create MixerDeviceSink with custom config