feat(qbz-audio): hold device reservation for AlsaDirectStream lifetime

Acquire org.freedesktop.ReserveDevice1.Audio<N> before opening the PCM,
hold for the entire stream lifetime, release on Drop after the PCM
closes. Closes the path where exclusive open would silently fight an
active PipeWire route.

Field order in AlsaDirectStream is significant: _reservation is the
last field so it drops AFTER the PCM closes, ensuring the kernel-level
exclusive lock is released before the D-Bus bus name is freed and
PipeWire reclaims the device.

This is the canonical Lifetime-A consumer the device_reservation
acquire doc-comment's tight-coupling rule allows: a DeviceReservation
created immediately before a real PCM::new() and held for as long as
the PCM is open. A 50 ms defensive sleep gives PipeWire time to vacate
the device after releasing the reservation.

cc
This commit is contained in:
vicrodh committed 2026-05-07 10:09:07 -06:00
1 parent 41b844cfa8
commit 92633dcf12
1 file changed
+42
+42
View File
@@ -13,6 +13,16 @@ use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex};
/// Direct ALSA PCM stream for hw: devices
///
/// Field order is significant: Rust drops struct fields top-to-bottom, so the
/// `PCM` is dropped first (releasing the kernel-level exclusive grip on the
/// `hw:` device) BEFORE `_reservation` drops (releasing the
/// `org.freedesktop.ReserveDevice1.Audio<N>` bus name back to PipeWire).
///
/// Reversing this order would tell PipeWire "go ahead, take the device" while
/// the kernel still has the FD open — guaranteed `EBUSY` ping-pong on the next
/// stream open. `_reservation` is intentionally the last field for that
/// reason; do not rearrange.
#[cfg(target_os = "linux")]
pub struct AlsaDirectStream {
pcm: Arc<Mutex<PCM>>,
@@ -22,6 +32,11 @@ pub struct AlsaDirectStream {
channels: u16,
format: Format,
device_id: String,
/// D-Bus device reservation held for the entire stream lifetime
/// (Lifetime A per the design spec). Acquired before `PCM::new()` in
/// `Self::new()`; released on `Drop` *after* the PCM closes (see field-order
/// note on the struct above).
_reservation: crate::DeviceReservation,
}
#[cfg(not(target_os = "linux"))]
@@ -42,6 +57,30 @@ impl AlsaDirectStream {
channels
);
// Acquire D-Bus device reservation BEFORE opening the PCM. This signals
// PipeWire/WirePlumber to release the device first if it currently
// holds it. Held for the entire `AlsaDirectStream` lifetime
// (Lifetime A per the design spec) and released on `Drop` after the
// PCM closes — see the field-order comment on the struct.
//
// This is the canonical Lifetime-A consumer the `acquire` doc-comment's
// tight-coupling rule allows: a `DeviceReservation` is created
// immediately before a real `PCM::new()` and held for as long as that
// PCM is open. The 50 ms sleep below gives PipeWire a defensive margin
// to vacate the device after releasing the reservation; observed ~2-4 ms
// in practice on the dev box but kept conservative.
let reservation =
crate::DeviceReservation::acquire(device_id, device_id).map_err(|e| {
format!(
"Cannot acquire exclusive device '{}': {}",
device_id, e
)
})?;
// Defensive margin between reservation acquisition and PCM open. Do not
// reduce or remove without an explicit instruction from the user.
std::thread::sleep(std::time::Duration::from_millis(50));
// Open PCM device
let pcm = PCM::new(device_id, Direction::Playback, false)
.map_err(|e| format!("Failed to open ALSA device '{}': {}", device_id, e))?;
@@ -133,6 +172,9 @@ impl AlsaDirectStream {
channels,
format: selected_format,
device_id: device_id.to_string(),
// Last field: drops after `pcm` so the kernel-level exclusive
// grip is released before the D-Bus bus name is freed.
_reservation: reservation,
})
}