feat(reservation): per-process DAC reservation + Tauri commands

Lifetime B from the design spec: a long-lived DeviceReservation guard
held in AppState, gated by the reserve_dac_while_running setting.
Acquired at startup if the persisted flag is true and the selected
device targets a single ALSA card; reapplied when the user picks a
different DAC; released on toggle-off, on a switch to a non-card
device, or AppState drop.

DB persistence: ALTER TABLE migration adds the column, SELECT and
reset_all are extended, set_reserve_dac_while_running setter parallels
the established pattern for boolean audio settings.

Tauri commands (V2 cleanroom):
- v2_set_reserve_dac_while_running(enabled)  - persist + apply
- v2_get_dac_reservation_status() -> DacReservationStatus

Status enum mirrors the four cases in the spec: Inactive (toggle off,
or non-card-specific device), Active (we hold the bus name), Contested
(another app holds the device at higher priority - guard is None but
the toggle is on), Unavailable (D-Bus unreachable, degraded guard).

v2_set_audio_output_device gains an AppState dependency so DAC changes
trigger a reservation handover when the toggle is on.

UI surfaces (Settings mirror + HiFi Wizard) land in Tasks 7-8.

cc
This commit is contained in:
vicrodh committed 2026-05-07 13:48:51 -06:00
1 parent a1a056c4b9
commit 4797fec939
3 files changed
+311 -12

No files matched your search

+150 -2
View File
@@ -138,6 +138,7 @@ pub async fn v2_set_audio_output_device(
device: Option<String>,
state: State<'_, AudioSettingsState>,
bridge: State<'_, CoreBridgeState>,
app_state: State<'_, crate::AppState>,
) -> Result<(), RuntimeError> {
let normalized_device = device
.as_ref()
@@ -147,7 +148,7 @@ pub async fn v2_set_audio_output_device(
device,
normalized_device
);
{
let lifetime_b_enabled = {
let guard = state
.store
.lock()
@@ -158,8 +159,21 @@ pub async fn v2_set_audio_output_device(
store
.set_output_device(normalized_device.as_deref())
.map_err(RuntimeError::Internal)?;
}
store
.get_settings()
.map_err(RuntimeError::Internal)?
.reserve_dac_while_running
};
sync_audio_settings_to_player(&state, &bridge).await;
// Lifetime B: when the user changes DAC, drop the old reservation
// and reacquire on the new card. Pass `None` if the new device isn't
// card-specific (apply_dac_reservation will release without
// reacquiring) or if the toggle is off.
if lifetime_b_enabled {
app_state
.apply_dac_reservation(normalized_device.as_deref(), normalized_device.as_deref());
}
Ok(())
}
@@ -663,3 +677,137 @@ pub async fn v2_set_audio_alsa_hardware_volume(
sync_audio_settings_to_player(&state, &bridge).await;
Ok(())
}
// ==================== DAC Reservation (Lifetime B) ====================
//
// See `qbz-nix-docs/specs/2026-05-07-alsa-exclusive-hardening-design.md`
// for the protocol-level design. Lifetime B = a long-lived
// `DeviceReservation` guard held in `AppState` for the QBZ process,
// gated by the `reserve_dac_while_running` audio setting.
/// Status payload returned by `v2_get_dac_reservation_status`.
///
/// `Inactive` covers the toggle-off and non-card-device cases. `Active`
/// means we currently hold the bus name. `Contested` means the toggle is
/// on but acquisition failed because another app holds the device at
/// equal-or-higher priority. `Unavailable` means the runtime environment
/// (no D-Bus session, sandbox without bus access) prevents any reservation
/// from working — surfaces as a degraded guard.
#[derive(Debug, serde::Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum DacReservationStatus {
Inactive,
Active,
Contested {
holder: String,
holder_priority: i32,
},
Unavailable {
reason: String,
},
}
/// Toggle the per-process DAC reservation (Lifetime B).
///
/// Persists the flag to `audio_settings` and applies it immediately:
/// `enabled=true` acquires a reservation for the current output device
/// (when it targets a single ALSA card); `enabled=false` releases the
/// guard. Idempotent — re-calling with the same value drops and
/// reacquires safely.
#[tauri::command]
pub async fn v2_set_reserve_dac_while_running(
enabled: bool,
state: State<'_, AudioSettingsState>,
app_state: State<'_, crate::AppState>,
) -> Result<(), RuntimeError> {
log::info!("[V2] set_reserve_dac_while_running: {}", enabled);
// 1. Persist the flag in the audio_settings DB.
let output_device = {
let guard = state
.store
.lock()
.map_err(|e| RuntimeError::Internal(format!("Lock error: {}", e)))?;
let store = guard
.as_ref()
.ok_or(RuntimeError::UserSessionNotActivated)?;
store
.set_reserve_dac_while_running(enabled)
.map_err(RuntimeError::Internal)?;
// Re-read the current device while we hold the lock — avoids a
// TOCTOU window where the user could change DAC between this
// setter and our apply call.
store
.get_settings()
.map_err(RuntimeError::Internal)?
.output_device
};
// 2. Apply: take or release the guard. None on disable; current
// output device on enable.
let device_for_apply = if enabled { output_device } else { None };
app_state.apply_dac_reservation(device_for_apply.as_deref(), device_for_apply.as_deref());
Ok(())
}
/// Snapshot the current Lifetime-B reservation state for the UI.
///
/// Reads both the persisted flag and the live guard so the UI can
/// distinguish between "user hasn't enabled it" (`Inactive`), "we own
/// it" (`Active`), "user enabled it but a higher-priority app holds the
/// DAC" (`Contested`), and "D-Bus is unavailable in this environment"
/// (`Unavailable`).
#[tauri::command]
pub fn v2_get_dac_reservation_status(
state: State<'_, AudioSettingsState>,
app_state: State<'_, crate::AppState>,
) -> Result<DacReservationStatus, RuntimeError> {
let snapshot = {
let guard = state
.store
.lock()
.map_err(|e| RuntimeError::Internal(format!("Lock error: {}", e)))?;
let store = guard
.as_ref()
.ok_or(RuntimeError::UserSessionNotActivated)?;
store.get_settings().map_err(RuntimeError::Internal)?
};
if !snapshot.reserve_dac_while_running {
return Ok(DacReservationStatus::Inactive);
}
match snapshot.output_device.as_deref() {
None => return Ok(DacReservationStatus::Inactive),
Some(d) if !is_card_specific_device(d) => {
return Ok(DacReservationStatus::Inactive);
}
_ => {}
}
let guard = app_state
.dac_reservation
.lock()
.map_err(|e| RuntimeError::Internal(format!("Lock error: {}", e)))?;
Ok(match guard.as_ref() {
Some(r) if r.is_active() => DacReservationStatus::Active,
Some(_) => DacReservationStatus::Unavailable {
reason: "D-Bus session bus unreachable or device not card-specific".to_string(),
},
None => DacReservationStatus::Contested {
holder: "unknown".to_string(),
holder_priority: 0,
},
})
}
/// Local mirror of `crate::is_card_specific_device` so the status
/// command's gating matches `apply_dac_reservation`'s gating without
/// crossing a module boundary just for one private helper.
fn is_card_specific_device(s: &str) -> bool {
s.starts_with("hw:")
|| s.starts_with("plughw:")
|| s.starts_with("front:")
|| s.starts_with("surround")
|| s.starts_with("iec958:")
|| s.starts_with("hdmi:")
}
+27 -5
View File
@@ -199,6 +199,10 @@ impl AudioSettingsStore {
"ALTER TABLE audio_settings ADD COLUMN allow_quality_fallback INTEGER DEFAULT 0",
[],
);
let _ = conn.execute(
"ALTER TABLE audio_settings ADD COLUMN reserve_dac_while_running INTEGER DEFAULT 0",
[],
);
Ok(Self { conn })
}
@@ -217,7 +221,7 @@ impl AudioSettingsStore {
pub fn get_settings(&self) -> Result<AudioSettings, String> {
self.conn
.query_row(
"SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds, streaming_only, limit_quality_to_device, device_max_sample_rate, normalization_enabled, normalization_target_lufs, gapless_enabled, device_sample_rate_limits, pw_force_bitperfect, sync_audio_on_startup, quality_fallback_behavior, skip_sink_switch, allow_quality_fallback FROM audio_settings WHERE id = 1",
"SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds, streaming_only, limit_quality_to_device, device_max_sample_rate, normalization_enabled, normalization_target_lufs, gapless_enabled, device_sample_rate_limits, pw_force_bitperfect, sync_audio_on_startup, quality_fallback_behavior, skip_sink_switch, allow_quality_fallback, reserve_dac_while_running FROM audio_settings WHERE id = 1",
[],
|row| {
// Parse backend_type from JSON string
@@ -260,9 +264,10 @@ impl AudioSettingsStore {
.unwrap_or_else(|| "ask".to_string()),
skip_sink_switch: row.get::<_, Option<i64>>(19)?.unwrap_or(0) != 0,
allow_quality_fallback: row.get::<_, Option<i64>>(20)?.unwrap_or(0) != 0,
// Persisted in Task 5 (Lifetime B). Default to false until the
// DB column + setter exist; serde_default keeps JSON round-trips clean.
reserve_dac_while_running: false,
reserve_dac_while_running: row
.get::<_, Option<i64>>(21)?
.unwrap_or(0)
!= 0,
})
},
)
@@ -514,6 +519,21 @@ impl AudioSettingsStore {
Ok(())
}
/// Persist the `reserve_dac_while_running` flag (Lifetime B from the
/// ALSA exclusive-hardening design spec). Toggling this only updates
/// the DB row; applying the change to the live `DeviceReservation`
/// guard is the caller's responsibility — see
/// `AppState::apply_dac_reservation`.
pub fn set_reserve_dac_while_running(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE audio_settings SET reserve_dac_while_running = ?1 WHERE id = 1",
params![enabled as i64],
)
.map_err(|e| format!("Failed to set reserve_dac_while_running: {}", e))?;
Ok(())
}
pub fn set_pw_force_bitperfect(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
@@ -612,7 +632,8 @@ impl AudioSettingsStore {
pw_force_bitperfect = ?17,
sync_audio_on_startup = ?18,
skip_sink_switch = ?19,
allow_quality_fallback = ?20
allow_quality_fallback = ?20,
reserve_dac_while_running = ?21
WHERE id = 1",
params![
defaults.output_device,
@@ -635,6 +656,7 @@ impl AudioSettingsStore {
defaults.sync_audio_on_startup as i64,
defaults.skip_sink_switch as i64,
defaults.allow_quality_fallback as i64,
defaults.reserve_dac_while_running as i64,
],
)
.map_err(|e| format!("Failed to reset audio settings: {}", e))?;
+134 -5
View File
@@ -77,7 +77,7 @@ pub mod visualizer;
pub mod qbzd_discovery;
use rustls::crypto::{aws_lc_rs, CryptoProvider};
use std::sync::Arc;
use std::sync::{Arc, Mutex as StdMutex};
use tauri::{Emitter, Manager};
use tokio::sync::{Mutex, RwLock};
@@ -87,6 +87,7 @@ use lastfm::LastFmClient;
use media_controls::{MediaControlsManager, TrackInfo};
use playback_context::ContextManager;
use player::Player;
use qbz_audio::DeviceReservation;
use queue::QueueManager;
use share::SongLinkClient;
use visualizer::Visualizer;
@@ -104,6 +105,14 @@ pub struct AppState {
pub visualizer: Visualizer,
#[cfg(target_os = "linux")]
pub idle_inhibitor: idle_inhibit::IdleInhibitor,
/// Long-lived per-process DAC reservation (Lifetime B from the
/// ALSA exclusive-hardening design spec). Held while the
/// `reserve_dac_while_running` audio setting is true and the
/// selected output device targets a single ALSA card.
/// Acquired at startup or via `v2_set_reserve_dac_while_running`,
/// dropped on toggle-off, DAC change to a non-card device, or
/// `AppState` drop.
pub dac_reservation: StdMutex<Option<DeviceReservation>>,
}
impl AppState {
@@ -166,6 +175,83 @@ impl AppState {
visualizer,
#[cfg(target_os = "linux")]
idle_inhibitor: idle_inhibit::IdleInhibitor::new(),
dac_reservation: StdMutex::new(None),
}
}
/// Apply the `reserve_dac_while_running` setting (Lifetime B from the
/// ALSA exclusive-hardening design spec).
///
/// Idempotent: drops any currently-held guard, then re-acquires for the
/// new device when both arguments make sense (Some + targets a single
/// ALSA card via a recognized plugin prefix). Passing `None` for
/// `hw_device`, or a device string the parser can't tie to one card
/// (`default`, `pulse`, etc.), releases the existing guard without
/// reacquiring.
///
/// `app_device_name` is the user-facing DAC label that will be advertised
/// over D-Bus once QBZ publishes the `ReserveDevice1` interface as a
/// server (see the design spec's "Note on `ApplicationName`" — that path
/// is deferred). Today the value is captured for logging only; pass the
/// same `hw_device` string if no friendlier name is at hand.
///
/// On acquisition failure (higher-priority holder, D-Bus error) the
/// guard is left as `None` and a warning is logged. Status is surfaced
/// to the UI via `v2_get_dac_reservation_status`.
pub fn apply_dac_reservation(
&self,
hw_device: Option<&str>,
app_device_name: Option<&str>,
) {
let mut guard = match self.dac_reservation.lock() {
Ok(g) => g,
Err(poisoned) => {
log::warn!(
"[reservation] dac_reservation mutex poisoned, recovering: {}",
poisoned
);
poisoned.into_inner()
}
};
// Drop any existing guard FIRST so the bus name is released before
// we attempt to reacquire (idempotent re-apply). This matters when
// the user changes DACs: the old card's bus name must release
// before the new card's bus name is requested, otherwise PipeWire
// may not have moved off the old card yet.
*guard = None;
let Some(hw) = hw_device else {
return;
};
if !is_card_specific_device(hw) {
return;
}
let app_name = app_device_name.unwrap_or(hw);
match DeviceReservation::acquire(hw, app_name) {
Ok(r) => {
if r.is_active() {
log::info!(
"[reservation] persistent DAC reservation acquired for {}",
hw
);
} else {
log::info!(
"[reservation] persistent DAC reservation degraded for {} (D-Bus unavailable or non-card device)",
hw
);
}
*guard = Some(r);
}
Err(e) => {
log::warn!(
"[reservation] persistent reservation failed for {}: {}",
hw,
e
);
// Leave guard as None; the v2_get_dac_reservation_status
// command surfaces the contended state to the UI.
}
}
}
}
@@ -176,6 +262,24 @@ impl Default for AppState {
}
}
/// Whether an ALSA device id targets a single card (so its `ReserveDevice1`
/// bus name has well-defined per-card semantics). Used by
/// `AppState::apply_dac_reservation` and `v2_get_dac_reservation_status` to
/// gate Lifetime-B reservation on devices the parser can map to one card.
///
/// Conservative allow-list: prefixes whose ALSA configurations all expand
/// to a single card. `default`, `pulse`, `null`, etc. are intentionally
/// excluded — they don't map to a single piece of hardware and a
/// per-card reservation has no defined meaning.
fn is_card_specific_device(s: &str) -> bool {
s.starts_with("hw:")
|| s.starts_with("plughw:")
|| s.starts_with("front:")
|| s.starts_with("surround")
|| s.starts_with("iec958:")
|| s.starts_with("hdmi:")
}
/// Update MPRIS metadata when track changes
pub fn update_media_controls_metadata(
media_controls: &MediaControlsManager,
@@ -808,11 +912,34 @@ pub fn run(qconnect_cli_override: Option<bool>) {
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init());
// Capture the bits we need to apply Lifetime-B DAC reservation before
// moving `audio_settings` into AppState. Reservation is opt-in via
// `reserve_dac_while_running` and only meaningful when the saved
// output device targets a single ALSA card (hw:/plughw:/front:/...).
let lifetime_b_enabled = audio_settings.reserve_dac_while_running;
let lifetime_b_device = if lifetime_b_enabled {
audio_settings.output_device.clone()
} else {
None
};
let app_state = AppState::with_device_and_settings(saved_device, audio_settings);
if lifetime_b_enabled {
if let Some(ref dev) = lifetime_b_device {
log::info!(
"[reservation] applying persisted Lifetime-B reservation at startup for {}",
dev
);
app_state.apply_dac_reservation(Some(dev), Some(dev));
} else {
log::info!(
"[reservation] reserve_dac_while_running is true but no output device is set; skipping startup reservation"
);
}
}
builder
.manage(AppState::with_device_and_settings(
saved_device,
audio_settings,
))
.manage(app_state)
.manage(core_bridge::CoreBridgeState::new())
.manage(commands_v2::OAuthCancelState::new())
.manage(qbzd_discovery::QbzdDiscoveryState::new())
@@ -1806,6 +1933,8 @@ pub fn run(qconnect_cli_override: Option<bool>) {
commands_v2::v2_set_audio_stream_first_track,
commands_v2::v2_set_audio_stream_buffer_seconds,
commands_v2::v2_set_audio_alsa_hardware_volume,
commands_v2::v2_set_reserve_dac_while_running,
commands_v2::v2_get_dac_reservation_status,
commands_v2::v2_listenbrainz_get_status,
commands_v2::v2_listenbrainz_is_enabled,
commands_v2::v2_listenbrainz_set_enabled,