feat(qbz-audio): add device-reservation module skeleton with parsers
Adds the new module that will host the org.freedesktop.ReserveDevice1 D-Bus client. This commit only ships the public surface, the parser helpers (hw:N -> Audio<N> bus name, hw:CARD=name resolution) and unit tests for them. acquire() currently returns a degraded guard; D-Bus implementation lands in the next commit. cc
This commit is contained in:
7 files changed
+259
No files matched your search
Generated
+3
@@ -2921,6 +2921,7 @@ dependencies = [
|
||||
"symphonia",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4387,6 +4388,7 @@ dependencies = [
|
||||
"signal-hook-registry",
|
||||
"socket2 0.6.3",
|
||||
"tokio-macros",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -5664,6 +5666,7 @@ dependencies = [
|
||||
"serde_repr",
|
||||
"sha1",
|
||||
"static_assertions",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"windows-sys 0.52.0",
|
||||
|
||||
@@ -41,6 +41,10 @@ dirs = "6"
|
||||
alsa = "0.10"
|
||||
# alsa-sys: direct FFI for snd_lib_error_set_handler (custom error routing)
|
||||
alsa-sys = "0.3"
|
||||
# zbus for org.freedesktop.ReserveDevice1 D-Bus client (device reservation).
|
||||
# Pinned to v4 to share the transitive copy already pulled in by
|
||||
# secret-service/souvlaki and avoid duplicate zbus compiles.
|
||||
zbus = { version = "4", default-features = false, features = ["tokio"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# CoreAudio for device probing, sample rate switching, and (future) hog mode / exclusive mode
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
//! D-Bus device reservation via org.freedesktop.ReserveDevice1.
|
||||
//!
|
||||
//! Acquires the bus name org.freedesktop.ReserveDevice1.Audio<N> for the
|
||||
//! card index N, signalling to PulseAudio/PipeWire/WirePlumber that another
|
||||
//! application owns the device exclusively. Released on Drop.
|
||||
//!
|
||||
//! See `qbz-nix-docs/specs/2026-05-07-alsa-exclusive-hardening-design.md`
|
||||
//! for the full protocol specification and lifetime model.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use linux::*;
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
mod stub;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub use stub::*;
|
||||
@@ -0,0 +1,193 @@
|
||||
//! Linux implementation of `DeviceReservation`.
|
||||
//!
|
||||
//! Task 1 ships the public surface, parser helpers, and unit tests for them.
|
||||
//! The actual zbus client is wired in Task 2 — for now `acquire()` validates
|
||||
//! the device string and returns a degraded guard so call sites can be
|
||||
//! plumbed without breaking the build.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DeviceReservation {
|
||||
state: ReservationState,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[allow(dead_code)] // `Active` is constructed in Task 2 once the zbus client lands.
|
||||
enum ReservationState {
|
||||
Active {
|
||||
// Filled in Task 2 with the zbus connection + bus name we own.
|
||||
_placeholder: (),
|
||||
},
|
||||
Degraded,
|
||||
}
|
||||
|
||||
impl DeviceReservation {
|
||||
/// Acquire a reservation for the given ALSA hw: device string.
|
||||
///
|
||||
/// Returns `Ok(guard)` on success. On D-Bus unavailability or any other
|
||||
/// non-fatal failure path, the implementation in Task 2 will return a
|
||||
/// degraded guard so playback proceeds without reservation. For now the
|
||||
/// device string is validated and a degraded guard is always returned.
|
||||
pub fn acquire(hw_device: &str, _app_device_name: &str) -> Result<Self, ReservationError> {
|
||||
// Validate the device string. Real D-Bus acquisition lands in Task 2.
|
||||
let _card = parse_card_index(hw_device)?;
|
||||
Ok(Self {
|
||||
state: ReservationState::Degraded,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether this guard currently holds an active D-Bus reservation.
|
||||
pub fn is_active(&self) -> bool {
|
||||
matches!(self.state, ReservationState::Active { .. })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DeviceReservation {
|
||||
fn drop(&mut self) {
|
||||
// Real release lands in Task 2.
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ReservationError {
|
||||
InvalidDevice(String),
|
||||
HigherPriorityHolder {
|
||||
holder_name: String,
|
||||
holder_priority: i32,
|
||||
},
|
||||
DbusError(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for ReservationError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidDevice(s) => write!(f, "invalid ALSA device string: {}", s),
|
||||
Self::HigherPriorityHolder {
|
||||
holder_name,
|
||||
holder_priority,
|
||||
} => write!(
|
||||
f,
|
||||
"device reserved by '{}' at priority {}",
|
||||
holder_name, holder_priority
|
||||
),
|
||||
Self::DbusError(s) => write!(f, "D-Bus error: {}", s),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ReservationError {}
|
||||
|
||||
/// Parse an ALSA device string and return the kernel card index.
|
||||
///
|
||||
/// Accepts: `"hw:0"`, `"hw:0,0"`, `"hw:1,0"`, `"plughw:1,0"`,
|
||||
/// `"hw:CARD=DacMagic"`, `"hw:CARD=DacMagic,DEV=0"`.
|
||||
pub(crate) fn parse_card_index(hw_device: &str) -> Result<u32, ReservationError> {
|
||||
let trimmed = hw_device.trim();
|
||||
let after_prefix = trimmed
|
||||
.strip_prefix("hw:")
|
||||
.or_else(|| trimmed.strip_prefix("plughw:"))
|
||||
.ok_or_else(|| ReservationError::InvalidDevice(hw_device.to_string()))?;
|
||||
|
||||
let card_part = after_prefix.split(',').next().unwrap_or("");
|
||||
let card_part = card_part.trim();
|
||||
|
||||
if card_part.is_empty() {
|
||||
return Err(ReservationError::InvalidDevice(hw_device.to_string()));
|
||||
}
|
||||
|
||||
if let Some(name) = card_part.strip_prefix("CARD=") {
|
||||
resolve_card_index_by_name(name)
|
||||
} else {
|
||||
card_part
|
||||
.parse::<u32>()
|
||||
.map_err(|_| ReservationError::InvalidDevice(hw_device.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a symbolic ALSA card name (e.g., `"DacMagic"`) to its kernel index
|
||||
/// by iterating over `alsa::card::Iter`.
|
||||
fn resolve_card_index_by_name(name: &str) -> Result<u32, ReservationError> {
|
||||
for card in alsa::card::Iter::new() {
|
||||
let card = card.map_err(|e| ReservationError::DbusError(e.to_string()))?;
|
||||
let id = card.get_name().unwrap_or_default();
|
||||
if id == name {
|
||||
return Ok(card.get_index() as u32);
|
||||
}
|
||||
}
|
||||
Err(ReservationError::InvalidDevice(format!(
|
||||
"ALSA card '{}' not found",
|
||||
name
|
||||
)))
|
||||
}
|
||||
|
||||
/// Format the well-known D-Bus bus name for a given ALSA card index.
|
||||
#[allow(dead_code)] // Used by the zbus client in Task 2.
|
||||
pub(crate) fn bus_name_for_card(card_index: u32) -> String {
|
||||
format!("org.freedesktop.ReserveDevice1.Audio{}", card_index)
|
||||
}
|
||||
|
||||
/// Format the D-Bus object path for a given ALSA card index.
|
||||
#[allow(dead_code)] // Used by the zbus client in Task 2.
|
||||
pub(crate) fn object_path_for_card(card_index: u32) -> String {
|
||||
format!("/org/freedesktop/ReserveDevice1/Audio{}", card_index)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_card_index_basic() {
|
||||
assert_eq!(parse_card_index("hw:0").unwrap(), 0);
|
||||
assert_eq!(parse_card_index("hw:1,0").unwrap(), 1);
|
||||
assert_eq!(parse_card_index("plughw:2,0").unwrap(), 2);
|
||||
assert_eq!(parse_card_index("hw:99,3").unwrap(), 99);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_card_index_rejects_garbage() {
|
||||
assert!(matches!(
|
||||
parse_card_index("default"),
|
||||
Err(ReservationError::InvalidDevice(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_card_index("hw:"),
|
||||
Err(ReservationError::InvalidDevice(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_card_index(""),
|
||||
Err(ReservationError::InvalidDevice(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bus_name_format() {
|
||||
assert_eq!(
|
||||
bus_name_for_card(0),
|
||||
"org.freedesktop.ReserveDevice1.Audio0"
|
||||
);
|
||||
assert_eq!(
|
||||
bus_name_for_card(7),
|
||||
"org.freedesktop.ReserveDevice1.Audio7"
|
||||
);
|
||||
assert_eq!(
|
||||
bus_name_for_card(99),
|
||||
"org.freedesktop.ReserveDevice1.Audio99"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_path_format() {
|
||||
assert_eq!(
|
||||
object_path_for_card(0),
|
||||
"/org/freedesktop/ReserveDevice1/Audio0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degraded_guard_reports_inactive() {
|
||||
let g = DeviceReservation::acquire("hw:0,0", "test").unwrap();
|
||||
assert!(!g.is_active());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! Non-Linux stub for `DeviceReservation`.
|
||||
//!
|
||||
//! The org.freedesktop.ReserveDevice1 protocol is a Linux/D-Bus convention.
|
||||
//! On macOS and Windows, `acquire()` always succeeds with a degraded guard
|
||||
//! so that call sites stay portable; `is_active()` always returns `false`.
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DeviceReservation;
|
||||
|
||||
impl DeviceReservation {
|
||||
/// Acquire a reservation for the given ALSA hw: device string.
|
||||
///
|
||||
/// On non-Linux platforms this always returns a degraded guard (no-op).
|
||||
pub fn acquire(_hw_device: &str, _app_device_name: &str) -> Result<Self, ReservationError> {
|
||||
Ok(Self)
|
||||
}
|
||||
|
||||
/// Whether this guard holds an active D-Bus reservation.
|
||||
///
|
||||
/// Always `false` on non-Linux platforms.
|
||||
pub fn is_active(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ReservationError {
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ReservationError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "device reservation is not supported on this platform")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ReservationError {}
|
||||
@@ -39,6 +39,7 @@ pub mod alsa_direct;
|
||||
pub mod coreaudio_direct;
|
||||
pub mod analysis;
|
||||
pub mod analyzer_tap;
|
||||
pub mod device_reservation;
|
||||
pub mod diagnostic;
|
||||
pub mod dynamic_amplify;
|
||||
pub mod loudness;
|
||||
@@ -61,6 +62,7 @@ pub use backend::{
|
||||
AlsaDirectError, AlsaPlugin, AudioBackend, AudioBackendType, AudioDevice, BackendConfig,
|
||||
BackendManager, BackendResult, BitPerfectMode,
|
||||
};
|
||||
pub use device_reservation::{DeviceReservation, ReservationError};
|
||||
pub use diagnostic::{AudioDiagnostic, BitDepthResult, DiagnosticSource};
|
||||
pub use dynamic_amplify::DynamicAmplify;
|
||||
pub use loudness::{calculate_gain_factor, db_to_linear, extract_replaygain, ReplayGainData};
|
||||
|
||||
Generated
+2
@@ -4888,6 +4888,7 @@ dependencies = [
|
||||
"symphonia",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"zbus 4.4.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9293,6 +9294,7 @@ dependencies = [
|
||||
"serde_repr",
|
||||
"sha1",
|
||||
"static_assertions",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"windows-sys 0.52.0",
|
||||
|
||||
Reference in new issue
Block a user