From c927eb9c9cefff5902da544b6062a8fe00243ccc Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:22:39 -0600 Subject: [PATCH] feat(qbzd): harden qconnect ownership lifecycle cx --- crates/qbzd/src/api/mod.rs | 109 +- crates/qbzd/src/api/play.rs | 17 +- crates/qbzd/src/api/playback.rs | 134 +- crates/qbzd/src/api/queue.rs | 40 +- crates/qbzd/src/api/radio.rs | 5 +- crates/qbzd/src/daemon.rs | 131 +- crates/qbzd/src/events_bridge.rs | 108 +- crates/qbzd/src/listen_log_engine.rs | 224 ++- crates/qbzd/src/mpris.rs | 92 +- crates/qbzd/src/qconnect/authority.rs | 9 + crates/qbzd/src/qconnect/delegation.rs | 1833 +++++++++++++++++++++ crates/qbzd/src/qconnect/engine.rs | 567 ++++++- crates/qbzd/src/qconnect/lan.rs | 526 ++++++ crates/qbzd/src/qconnect/mod.rs | 1128 +++++++++++-- crates/qbzd/src/qconnect/publish.rs | 122 +- crates/qbzd/src/qconnect/remote_stream.rs | 149 +- crates/qbzd/src/qconnect/report.rs | 89 +- crates/qbzd/src/qconnect/session.rs | 254 ++- crates/qbzd/src/qconnect/sink.rs | 159 +- crates/qbzd/src/qconnect/transport.rs | 76 +- crates/qbzd/src/scrobble_engine.rs | 134 +- crates/qbzd/src/state.rs | 55 +- 22 files changed, 5560 insertions(+), 401 deletions(-) create mode 100644 crates/qbzd/src/qconnect/authority.rs create mode 100644 crates/qbzd/src/qconnect/delegation.rs create mode 100644 crates/qbzd/src/qconnect/lan.rs diff --git a/crates/qbzd/src/api/mod.rs b/crates/qbzd/src/api/mod.rs index d40321c46..1eceee26c 100644 --- a/crates/qbzd/src/api/mod.rs +++ b/crates/qbzd/src/api/mod.rs @@ -32,7 +32,7 @@ pub mod status; use std::io::Cursor; use std::net::SocketAddr; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, OnceLock}; use std::time::Instant; use tiny_http::{Header, Method, Request, Response}; @@ -536,6 +536,75 @@ pub(crate) fn err_json( json(status, error_body(code, message, hint)) } +/// Refuse owner-only queue and track-selection actions while the QConnect +/// authority cell belongs to a delegated runtime (or is fenced during an +/// authority transition). Transport controls that are safe for either origin +/// -- pause/resume/seek/volume -- deliberately do not call this OWNER gate; +/// they use [`transport_action_lease`] so they still drain before a handoff. +pub(crate) fn owner_action_gate(state: &ApiState) -> Option>>> { + owner_actions_blocked(state.qconnect_control.as_ref()) + .then(|| json(409, owner_action_conflict_body())) +} + +/// Atomically admit one owner-only action. The returned permit must stay alive +/// through every queue/playback mutation and await in that action; authority +/// handoff closes new admission and drains these permits before snapshotting. +/// `None` inside `Ok` is the short boot window before QConnect publishes its +/// control handle, when no delegated runtime can exist yet. +pub(crate) fn owner_action_lease( + state: &ApiState, +) -> Result, Response>>> { + let Some(qconnect) = state.qconnect_control.get() else { + return Ok(None); + }; + qconnect + .try_owner_action_permit() + .map(Some) + .ok_or_else(|| json(409, owner_action_conflict_body())) +} + +/// Atomically admit an origin-agnostic transport action. A stable owner or +/// delegated renderer may be controlled locally, but a transition fence +/// refuses the action so it cannot land on the runtime installed after the +/// handoff. As with owner leases, `None` is only the pre-QConnect boot window. +pub(crate) fn transport_action_lease( + state: &ApiState, +) -> Result, Response>>> { + let Some(qconnect) = state.qconnect_control.get() else { + return Ok(None); + }; + qconnect + .try_transport_action_permit() + .map(Some) + .ok_or_else(|| json(409, transport_action_conflict_body())) +} + +/// Cheap form used by spawn-and-ack handlers immediately before their async +/// owner action starts. An unpublished control means QConnect has not reached +/// daemon boot step 12 yet, so there is no delegated authority to conflict +/// with. +pub(crate) fn owner_actions_blocked(control: &OnceLock) -> bool { + control + .get() + .is_some_and(|qconnect| !qconnect.owner_actions_allowed()) +} + +fn owner_action_conflict_body() -> serde_json::Value { + error_body( + "qconnect_authority_conflict", + "the owner queue is unavailable while QConnect controls playback", + "return playback control to the QBZ owner and try again", + ) +} + +fn transport_action_conflict_body() -> serde_json::Value { + error_body( + "qconnect_authority_transition", + "playback control is temporarily unavailable during a QConnect handoff", + "retry the transport command", + ) +} + pub(crate) fn error_body(code: &str, message: &str, hint: &str) -> serde_json::Value { serde_json::json!({"error": {"code": code, "message": message, "hint": hint}}) } @@ -683,4 +752,42 @@ mod tests { assert_eq!(body["error"]["message"], "refused"); assert_eq!(body["error"]["hint"], "not a browser API"); } + + #[test] + fn owner_action_conflict_is_stable_and_contains_no_handoff_details() { + let body = owner_action_conflict_body(); + assert_eq!(body["error"]["code"], "qconnect_authority_conflict"); + assert_eq!( + body["error"]["message"], + "the owner queue is unavailable while QConnect controls playback" + ); + + let encoded = serde_json::to_string(&body).unwrap(); + for secret_shape in [ + "Authorization", + "Bearer", + "jwt", + "session_id", + "endpoint", + "http://", + "https://", + "?", + ] { + assert!( + !encoded.contains(secret_shape), + "conflict response reflected sensitive shape {secret_shape}" + ); + } + } + + #[test] + fn transport_transition_conflict_is_retryable_and_origin_agnostic() { + let body = transport_action_conflict_body(); + assert_eq!(body["error"]["code"], "qconnect_authority_transition"); + assert_eq!(body["error"]["hint"], "retry the transport command"); + let message = body["error"]["message"].as_str().unwrap(); + assert!(message.contains("handoff")); + assert!(!message.contains("owner")); + assert!(!message.contains("delegated")); + } } diff --git a/crates/qbzd/src/api/play.rs b/crates/qbzd/src/api/play.rs index 00c014091..d943e73fb 100644 --- a/crates/qbzd/src/api/play.rs +++ b/crates/qbzd/src/api/play.rs @@ -29,13 +29,16 @@ use qbz_qobuz::link_resolver::{resolve_link, ResolvedLink}; use crate::state::AuthState; use super::queue::track_to_queue_track; -use super::{err_json, json, ApiState}; +use super::{err_json, json, owner_action_gate, owner_action_lease, ApiState}; /// Top-tracks cap when playing a whole artist — a sane "play this artist" set, /// not their entire catalogue. const ARTIST_TOP_LIMIT: u32 = 50; pub fn play(state: &ApiState, body: &Value) -> Response>> { + if let Some(resp) = owner_action_gate(state) { + return resp; + } if let Some(resp) = auth_gate(state) { return resp; } @@ -65,6 +68,9 @@ pub(crate) fn start_resolved( context: Option<(&'static str, String)>, start_index: Option, ) -> Response>> { + if let Some(resp) = owner_action_gate(state) { + return resp; + } if tracks.is_empty() { return err_json(404, "not_found", "nothing to play", "check the id: qbzd search "); } @@ -82,6 +88,14 @@ pub(crate) fn start_resolved( let start_track_id = queue_tracks[start].id; let start_summary = summary(&queue_tracks[start]); + // `play` and `radio` both perform owner catalog I/O before reaching this + // point. A handoff may commit during that I/O, so fence again immediately + // before replacing the queue. + let owner_lease = match owner_action_lease(state) { + Ok(lease) => lease, + Err(response) => return response, + }; + state .rt .block_on(state.runtime.core().set_queue(queue_tracks, Some(start))); @@ -94,6 +108,7 @@ pub(crate) fn start_resolved( let runtime = std::sync::Arc::clone(&state.runtime); let shared = std::sync::Arc::clone(&state.shared); state.rt.spawn(async move { + let _owner_lease = owner_lease; let played = runtime .core() .play_track_resolved(start_track_id, quality, None, None, 0) diff --git a/crates/qbzd/src/api/playback.rs b/crates/qbzd/src/api/playback.rs index 3eb000af3..140122e0c 100644 --- a/crates/qbzd/src/api/playback.rs +++ b/crates/qbzd/src/api/playback.rs @@ -33,7 +33,10 @@ use tiny_http::Response; use crate::state::AuthState; -use super::{canon_volume, err_json, json, ApiState}; +use super::{ + canon_volume, err_json, json, owner_action_gate, owner_action_lease, transport_action_lease, + ApiState, +}; /// `GET /api/now-playing` (02 §3.3.4). `playback` is the serialized /// `PlaybackEvent` (qbz-player/src/player/mod.rs:925) with `shuffle`/`repeat` @@ -74,20 +77,30 @@ pub fn now_playing(state: &ApiState) -> Response>> { .map(|t| serde_json::to_value(t).unwrap_or(Value::Null)) .unwrap_or(Value::Null); - json(200, serde_json::json!({"playback": playback, "track": track})) + json( + 200, + serde_json::json!({"playback": playback, "track": track}), + ) } /// `POST /api/playback/play` (02 §3.3.5). Resume if paused; cold-start the /// current queue track when `!has_loaded_audio()` (the desktop's /// `toggle_play_pause` cold-start branch, `crates/qbz/src/playback.rs:3837-3860`). pub fn play(state: &ApiState) -> Response>> { - let player = state.runtime.core().player(); - if player.has_loaded_audio() { + let transport_lease = match transport_action_lease(state) { + Ok(lease) => lease, + Err(response) => return response, + }; + if state.runtime.core().player().has_loaded_audio() { return match state.runtime.core().resume() { Ok(()) => json(200, serde_json::json!({"state": "playing"})), Err(e) => runtime_error(&e.to_string()), }; } + // Cold start selects/resolves the owner queue and therefore needs the + // stricter owner lease. Drop the transport permit first; `cold_start` + // revalidates authority before its first mutation. + drop(transport_lease); // The cold-start load is spawn-and-ack: report "loading" (honest ack); // `qbzd now` / SSE show the transition to playing. match cold_start(state) { @@ -100,6 +113,10 @@ pub fn play(state: &ApiState) -> Response>> { /// 0 · 1 · 3 (no 5, §2.2) so a `Player::pause` channel failure is /// [`runtime_error`], not [`device_error`]. pub fn pause(state: &ApiState) -> Response>> { + let _transport_lease = match transport_action_lease(state) { + Ok(lease) => lease, + Err(response) => return response, + }; match state.runtime.core().pause() { Ok(()) => json(200, serde_json::json!({"state": "paused"})), Err(e) => runtime_error(&e.to_string()), @@ -112,6 +129,10 @@ pub fn pause(state: &ApiState) -> Response>> { /// the cold-start branch (`cold_start`'s own [`device_error`]) — the /// pause/resume branches use [`runtime_error`] like plain `pause`/`stop`. pub fn toggle(state: &ApiState) -> Response>> { + let transport_lease = match transport_action_lease(state) { + Ok(lease) => lease, + Err(response) => return response, + }; let player = state.runtime.core().player(); let ev = player.get_playback_event(); if ev.is_playing { @@ -126,6 +147,9 @@ pub fn toggle(state: &ApiState) -> Response>> { Err(e) => runtime_error(&e.to_string()), }; } + // The remaining branch is owner queue selection/stream resolution, not a + // primitive transport command. Re-admit it under owner authority. + drop(transport_lease); // The cold-start load is spawn-and-ack: report "loading" (honest ack); // `qbzd now` / SSE show the transition to playing. match cold_start(state) { @@ -137,6 +161,10 @@ pub fn toggle(state: &ApiState) -> Response>> { /// `POST /api/playback/stop` (02 §3.3.8). Never cold-starts; same exit-set /// reasoning as `pause`. pub fn stop(state: &ApiState) -> Response>> { + let _transport_lease = match transport_action_lease(state) { + Ok(lease) => lease, + Err(response) => return response, + }; match state.runtime.core().stop() { Ok(()) => json(200, serde_json::json!({"state": "stopped"})), Err(e) => runtime_error(&e.to_string()), @@ -160,6 +188,10 @@ pub fn previous(state: &ApiState) -> Response>> { /// an async command to the audio thread; the clamp is deterministic so the /// "post-state" is knowable synchronously. pub fn seek(state: &ApiState, body: &Value) -> Response>> { + let _transport_lease = match transport_action_lease(state) { + Ok(lease) => lease, + Err(response) => return response, + }; let player = state.runtime.core().player(); if player.is_dsd_direct_active() { return err_json( @@ -182,11 +214,18 @@ pub fn seek(state: &ApiState, body: &Value) -> Response>> { "body: {\"position\": 90} or {\"delta\": -10}", ); }; - let clamped = if ev.duration > 0 { target.min(ev.duration) } else { target }; + let clamped = if ev.duration > 0 { + target.min(ev.duration) + } else { + target + }; if let Err(e) = state.runtime.core().seek(clamped) { return runtime_error(&e.to_string()); } - json(200, serde_json::json!({"position": clamped, "duration": ev.duration})) + json( + 200, + serde_json::json!({"position": clamped, "duration": ev.duration}), + ) } /// `POST /api/playback/volume` (02 §3.3.12). One of three body forms: @@ -194,6 +233,10 @@ pub fn seek(state: &ApiState, body: &Value) -> Response>> { /// `{"mute": "on"|"off"|"toggle"}` (also `qbzd mute`'s route — no dedicated /// route, §2.2). All three are gated by the same DSD-direct guard as `seek`. pub fn volume(state: &ApiState, body: &Value) -> Response>> { + let _transport_lease = match transport_action_lease(state) { + Ok(lease) => lease, + Err(response) => return response, + }; let player = state.runtime.core().player(); if player.is_dsd_direct_active() { return err_json( @@ -236,7 +279,10 @@ pub fn volume(state: &ApiState, body: &Value) -> Response>> { if let Err(e) = state.runtime.core().set_volume(target) { return runtime_error(&e.to_string()); } - json(200, serde_json::json!({"volume": canon_volume(target), "muted": muted_after})) + json( + 200, + serde_json::json!({"volume": canon_volume(target), "muted": muted_after}), + ) } /// `POST /api/playback/shuffle` (CONSOLE). Body `{"mode": "on"|"off"|"toggle"}` @@ -244,7 +290,17 @@ pub fn volume(state: &ApiState, body: &Value) -> Response>> { /// queue-mode toggle touches no Qobuz session; state also surfaces in /// `/api/status` and `/api/now-playing`. pub fn shuffle(state: &ApiState, body: &Value) -> Response>> { - let mode = body.get("mode").and_then(|v| v.as_str()).unwrap_or("toggle"); + if let Some(resp) = owner_action_gate(state) { + return resp; + } + let mode = body + .get("mode") + .and_then(|v| v.as_str()) + .unwrap_or("toggle"); + let _owner_lease = match owner_action_lease(state) { + Ok(lease) => lease, + Err(response) => return response, + }; let enabled = match mode { "on" => { state.rt.block_on(state.runtime.core().set_shuffle(true)); @@ -256,7 +312,12 @@ pub fn shuffle(state: &ApiState, body: &Value) -> Response>> { } "toggle" => state.rt.block_on(state.runtime.core().toggle_shuffle()), other => { - return err_json(400, "bad_request", &format!("invalid mode '{other}'"), "mode: on | off | toggle") + return err_json( + 400, + "bad_request", + &format!("invalid mode '{other}'"), + "mode: on | off | toggle", + ) } }; json(200, serde_json::json!({"shuffle": enabled})) @@ -265,15 +326,27 @@ pub fn shuffle(state: &ApiState, body: &Value) -> Response>> { /// `POST /api/playback/repeat` (CONSOLE). Body `{"mode": "off"|"all"|"one"}`. /// No auth. Returns the applied mode. pub fn repeat(state: &ApiState, body: &Value) -> Response>> { + if let Some(resp) = owner_action_gate(state) { + return resp; + } let mode = body.get("mode").and_then(|v| v.as_str()).unwrap_or(""); let rm = match mode { "off" => qbz_models::RepeatMode::Off, "all" => qbz_models::RepeatMode::All, "one" => qbz_models::RepeatMode::One, other => { - return err_json(400, "bad_request", &format!("invalid repeat mode '{other}'"), "mode: off | all | one") + return err_json( + 400, + "bad_request", + &format!("invalid repeat mode '{other}'"), + "mode: off | all | one", + ) } }; + let _owner_lease = match owner_action_lease(state) { + Ok(lease) => lease, + Err(response) => return response, + }; state.rt.block_on(state.runtime.core().set_repeat_mode(rm)); json(200, serde_json::json!({"repeat": mode})) } @@ -302,7 +375,12 @@ fn apply_mute(state: &ApiState, live: f32, arg: &str) -> Response let mut guard = match state.shared.lock() { Ok(g) => g, Err(_) => { - return err_json(500, "internal", "daemon state lock poisoned", "restart qbzd") + return err_json( + 500, + "internal", + "daemon state lock poisoned", + "restart qbzd", + ) } }; @@ -315,13 +393,21 @@ fn apply_mute(state: &ApiState, live: f32, arg: &str) -> Response guard.muted = true; (stash, state.runtime.core().set_volume(0.0)) } else if !mute_on && guard.muted { - let restored = if guard.premute_volume > 0.0 { guard.premute_volume } else { 0.7 }; + let restored = if guard.premute_volume > 0.0 { + guard.premute_volume + } else { + 0.7 + }; guard.muted = false; (restored, state.runtime.core().set_volume(restored)) } else { // Already in the requested state — a no-op that still reports the // current nominal level. - let nominal = if guard.muted { guard.premute_volume } else { live }; + let nominal = if guard.muted { + guard.premute_volume + } else { + live + }; (nominal, Ok(())) }; let muted_now = guard.muted; @@ -330,7 +416,10 @@ fn apply_mute(state: &ApiState, live: f32, arg: &str) -> Response if let Err(e) = set_result { return runtime_error(&e.to_string()); } - json(200, serde_json::json!({"volume": canon_volume(nominal), "muted": muted_now})) + json( + 200, + serde_json::json!({"volume": canon_volume(nominal), "muted": muted_now}), + ) } /// `next`/`previous` (02 §3.3.9-10): gate on NeedsAuth BEFORE running the @@ -347,13 +436,21 @@ fn apply_mute(state: &ApiState, live: f32, arg: &str) -> Response /// The landing track is no longer reported synchronously — follow it via /// `qbzd now` / SSE. fn advance(state: &ApiState, forward: bool) -> Response>> { + if let Some(resp) = owner_action_gate(state) { + return resp; + } if let Some(resp) = auth_gate(state) { return resp; } + let owner_lease = match owner_action_lease(state) { + Ok(lease) => lease, + Err(response) => return response, + }; let quality = resolve_quality(state); let runtime = std::sync::Arc::clone(&state.runtime); let shared = Arc::clone(&state.shared); state.rt.spawn(async move { + let _owner_lease = owner_lease; if let Err(err) = qbz_app::playback_driver::advance_and_play(runtime.as_ref(), quality, forward).await { @@ -382,6 +479,7 @@ fn advance(state: &ApiState, forward: bool) -> Response>> { /// `last_errors.stream` instead of a 5xx. Ok(()) means "load queued" — the /// callers answer `{"state": "loading"}`. fn cold_start(state: &ApiState) -> Result<(), Response>>> { + let owner_lease = owner_action_lease(state)?; if let Some(resp) = auth_gate(state) { return Err(resp); } @@ -402,6 +500,7 @@ fn cold_start(state: &ApiState) -> Result<(), Response>>> { let runtime = std::sync::Arc::clone(&state.runtime); let shared = Arc::clone(&state.shared); state.rt.spawn(async move { + let _owner_lease = owner_lease; let played = runtime .core() .play_track_resolved(track_id, quality, None, None, 0) @@ -435,7 +534,12 @@ fn auth_gate(state: &ApiState) -> Option>>> { .map(|s| s.auth == AuthState::NeedsAuth) .unwrap_or(false); if needs_auth { - Some(err_json(409, "needs_auth", "not logged in to Qobuz", "run: qbzd login")) + Some(err_json( + 409, + "needs_auth", + "not logged in to Qobuz", + "run: qbzd login", + )) } else { None } diff --git a/crates/qbzd/src/api/queue.rs b/crates/qbzd/src/api/queue.rs index 1ddffe57f..998178937 100644 --- a/crates/qbzd/src/api/queue.rs +++ b/crates/qbzd/src/api/queue.rs @@ -41,7 +41,7 @@ use qbz_models::{QueueTrack, RepeatMode, Track}; use crate::state::AuthState; -use super::{err_json, json, ApiState}; +use super::{err_json, json, owner_action_gate, owner_action_lease, ApiState}; /// `GET /api/queue` (02 §3.3.13). `query` is the raw query string (no leading /// `?`) — `route()` strips it off the path before dispatch, so it is threaded @@ -100,6 +100,9 @@ pub fn list(state: &ApiState, query: &str) -> Response>> { /// STRICT and run before any core call, so a rejected body never partially /// mutates the queue). pub fn add(state: &ApiState, body: &Value) -> Response>> { + if let Some(resp) = owner_action_gate(state) { + return resp; + } if let Some(resp) = auth_gate(state) { return resp; } @@ -134,6 +137,14 @@ pub fn add(state: &ApiState, body: &Value) -> Response>> { .map(|t| serde_json::to_value(t).unwrap_or(Value::Null)) .collect(); + // Catalog resolution can outlive an authority handoff. Revalidate before + // the first owner queue mutation so a guest epoch cannot inherit this + // request merely because it began under owner authority. + let _owner_lease = match owner_action_lease(state) { + Ok(lease) => lease, + Err(response) => return response, + }; + if position == AddPosition::Next { // `add_track_next` always inserts immediately after the current // track, so reverse iteration is what lands multiple tracks in @@ -158,6 +169,10 @@ pub fn add(state: &ApiState, body: &Value) -> Response>> { /// playing track — the verbatim hint below is quoted §3.3.15; also a /// missing or non-integer `index` field, with distinct messages). pub fn remove(state: &ApiState, body: &Value) -> Response>> { + let _owner_lease = match owner_action_lease(state) { + Ok(lease) => lease, + Err(response) => return response, + }; let index = match parse_remove_index(body) { Ok(i) => i, Err((message, hint)) => return err_json(400, "bad_request", &message, &hint), @@ -214,6 +229,10 @@ pub fn remove(state: &ApiState, body: &Value) -> Response>> { /// (default `true` when the field is absent — the CLI always sends it /// explicitly, §"queue clear" in cli/queue.rs). pub fn clear(state: &ApiState, body: &Value) -> Response>> { + let _owner_lease = match owner_action_lease(state) { + Ok(lease) => lease, + Err(response) => return response, + }; let keep_current = body.get("keep_current").and_then(|v| v.as_bool()).unwrap_or(true); state.rt.block_on(state.runtime.core().clear_queue(keep_current)); let total_tracks = state.rt.block_on(state.runtime.core().get_queue_state()).total_tracks; @@ -223,6 +242,9 @@ pub fn clear(state: &ApiState, body: &Value) -> Response>> { /// `POST /api/queue/move` (CONSOLE). Body `{"from": N, "to": N}` (0-based). /// GUI drag-reorder (core `move_track`). 404 when either index is out of range. pub fn reorder(state: &ApiState, body: &Value) -> Response>> { + if let Some(resp) = owner_action_gate(state) { + return resp; + } let from = match body.get("from").and_then(|v| v.as_u64()) { Some(n) => n as usize, None => return err_json(400, "bad_request", "move requires 'from' and 'to'", "body: {\"from\": 7, \"to\": 2}"), @@ -231,6 +253,10 @@ pub fn reorder(state: &ApiState, body: &Value) -> Response>> { Some(n) => n as usize, None => return err_json(400, "bad_request", "move requires 'from' and 'to'", "body: {\"from\": 7, \"to\": 2}"), }; + let _owner_lease = match owner_action_lease(state) { + Ok(lease) => lease, + Err(response) => return response, + }; if state.rt.block_on(state.runtime.core().move_track(from, to)) { json(200, serde_json::json!({"from": from, "to": to})) } else { @@ -246,6 +272,9 @@ pub fn reorder(state: &ApiState, body: &Value) -> Response>> { /// spawn-and-ack (see `playback::advance`) so a slow fetch can't starve the /// single-threaded API — failures latch into `last_errors.stream`. pub fn jump(state: &ApiState, body: &Value) -> Response>> { + if let Some(resp) = owner_action_gate(state) { + return resp; + } if let Some(resp) = auth_gate(state) { return resp; } @@ -253,6 +282,10 @@ pub fn jump(state: &ApiState, body: &Value) -> Response>> { Some(n) => n as usize, None => return err_json(400, "bad_request", "jump requires an 'index'", "body: {\"index\": 2}"), }; + let owner_lease = match owner_action_lease(state) { + Ok(lease) => lease, + Err(response) => return response, + }; let track = match state.rt.block_on(state.runtime.core().play_index(index)) { Some(t) => t, None => return err_json(404, "not_found", &format!("queue index {index} is out of range"), "check: qbzd queue list"), @@ -262,6 +295,7 @@ pub fn jump(state: &ApiState, body: &Value) -> Response>> { let runtime = std::sync::Arc::clone(&state.runtime); let shared = std::sync::Arc::clone(&state.shared); state.rt.spawn(async move { + let _owner_lease = owner_lease; if let Err(err) = runtime .core() .play_track_resolved(track_id, quality, None, None, 0) @@ -285,6 +319,10 @@ pub fn jump(state: &ApiState, body: &Value) -> Response>> { /// `{"current": true}` | `{"off": true}`. Sets/clears the stop-after gate /// (core `set_stop_after`/`clear_stop_after`). pub fn stop_after(state: &ApiState, body: &Value) -> Response>> { + let _owner_lease = match owner_action_lease(state) { + Ok(lease) => lease, + Err(response) => return response, + }; if body.get("off").and_then(|v| v.as_bool()).unwrap_or(false) { state.rt.block_on(state.runtime.core().clear_stop_after()); return json(200, serde_json::json!({"stop_after_track_id": Value::Null})); diff --git a/crates/qbzd/src/api/radio.rs b/crates/qbzd/src/api/radio.rs index dc02a6eaf..4cea86cd3 100644 --- a/crates/qbzd/src/api/radio.rs +++ b/crates/qbzd/src/api/radio.rs @@ -17,12 +17,15 @@ use qbz_models::Track; use crate::state::AuthState; -use super::{err_json, ApiState}; +use super::{err_json, owner_action_gate, ApiState}; /// `POST /api/radio`. Body: one of `{"artist_id": N}` | `{"track_id": N}` | /// `{"album_id": "..."}`. Errors: 409 needs_auth, 400 bad_request (no seed), /// 502 radio_failed (upstream), 404/503 from `start_resolved` (empty / start). pub fn radio(state: &ApiState, body: &Value) -> Response>> { + if let Some(resp) = owner_action_gate(state) { + return resp; + } if let Some(resp) = auth_gate(state) { return resp; } diff --git a/crates/qbzd/src/daemon.rs b/crates/qbzd/src/daemon.rs index ec66466ac..2c02cec96 100644 --- a/crates/qbzd/src/daemon.rs +++ b/crates/qbzd/src/daemon.rs @@ -6,9 +6,9 @@ // parks on signals — API-less but fully diagnosable in-process. use std::sync::{Arc, Mutex}; +use qbz_app::playback_driver::{self, DriverDeps}; use qbz_app::settings::daemon_prefs; use qbz_app::shell::AppRuntime; -use qbz_app::playback_driver::{self, DriverDeps}; use qbz_core::CoreError; use qbz_models::{CoreEvent, UserSession}; use tokio::sync::broadcast; @@ -111,6 +111,12 @@ pub async fn run(roots: ProfileRoots, cfg: QbzdConfig, warns: Vec) -> Re // (T9/T10) splice after this, reading `booted`. let prefs = daemon_prefs::load_at(&roots.data); let quality = playback_driver::quality_from_key(&prefs.streaming_quality); + let qconnect_authority = Arc::new(crate::qconnect::authority::AuthorityCell::new()); + // Owner-only playback consumers use a parallel, daemon-local bus whose + // events carry the exact authority observation made by events_bridge. + // Keep the public CoreEvent bus unchanged for SSE/MPRIS/hooks. + let (owner_event_tx, _) = + broadcast::channel::(256); // T11: a live-updatable cell, not a value captured once — `settings/reload` // re-reads `daemon_prefs` and writes here so the driver's OWN auto-advance // (gapless prefetch, natural-end advance) picks up a `playback.quality` @@ -132,6 +138,7 @@ pub async fn run(roots: ProfileRoots, cfg: QbzdConfig, warns: Vec) -> Re report_notify.clone(), edge_notify.clone(), booted.bus.clone(), + qconnect_authority.clone(), ); let driver = tokio::spawn(playback_driver::run_driver( booted.runtime.clone(), @@ -145,20 +152,33 @@ pub async fn run(roots: ProfileRoots, cfg: QbzdConfig, warns: Vec) -> Re // flushed to the session store, so a restart resumes the remote-set queue // PAUSED (boot already restores it, §8.1-9½). Holds an `Arc` // clone, so it is aborted+joined ahead of `drop(booted)` (#521 ordering). - let queue_persist = spawn_queue_persist(booted.runtime.clone(), booted.bus.subscribe()); + let queue_persist = spawn_queue_persist( + booted.runtime.clone(), + booted.bus.subscribe(), + qconnect_authority.clone(), + ); - // 10c. Scrobble-on-play (CONSOLE): a CoreEvent-bus subscriber that sends + // 10c. Scrobble-on-play (CONSOLE): an authority-stamped event subscriber that sends // "now playing" on TrackStarted and scrobbles once past the Last.fm // threshold, to whichever of Last.fm / ListenBrainz is connected + // enabled in the scrobbler store. Holds NO Arc, so it sits // outside the #521/§8.2 ordering — aborted for a clean shutdown below. - let scrobbler = crate::scrobble_engine::spawn(roots.clone(), booted.bus.subscribe()); + let scrobbler = crate::scrobble_engine::spawn( + roots.clone(), + owner_event_tx.subscribe(), + qconnect_authority.clone(), + ); - // 10c¼. Listen log (§12.1): the scrobbler's sibling on the same bus, + // 10c¼. Listen log (§12.1): the scrobbler's sibling on the same stamped bus, // WITHOUT its enabled gate — the log is local and on by default, so // a headless streamer finally contributes history. Holds no // Arc; stopped (abort + close the open row) below. - let listen_log = crate::listen_log_engine::spawn(&roots, booted.bus.subscribe()).await; + let listen_log = crate::listen_log_engine::spawn( + &roots, + owner_event_tx.subscribe(), + qconnect_authority.clone(), + ) + .await; // 10c½. Events bridge: translate the driver's transition edges into the // playback CoreEvents the bus consumers above (and SSE, and the event @@ -166,8 +186,13 @@ pub async fn run(roots: ProfileRoots, cfg: QbzdConfig, warns: Vec) -> Re // PositionUpdated / VolumeChanged. Holds only a Weak // upgraded per wake, but is still aborted+joined ahead of // `drop(booted)` so a mid-wake strong Arc can't outlive the ordering. - let events_bridge = - crate::events_bridge::spawn(&booted.runtime, booted.bus.clone(), edge_notify); + let events_bridge = crate::events_bridge::spawn( + &booted.runtime, + booted.bus.clone(), + owner_event_tx, + edge_notify, + qconnect_authority.clone(), + ); // 10c¾. Event hook (CONSOLE): fork `hooks.script` (or `QBZD_HOOK`) once per // forwarded bus event with QBZ_* variables in its environment — push @@ -191,6 +216,7 @@ pub async fn run(roots: ProfileRoots, cfg: QbzdConfig, warns: Vec) -> Re roots.clone(), booted.bus.subscribe(), tokio::runtime::Handle::current(), + qconnect_authority.clone(), ); // 11. HTTP serve (02 §3) on the already-bound socket. `ApiState` carries a @@ -239,6 +265,7 @@ pub async fn run(roots: ProfileRoots, cfg: QbzdConfig, warns: Vec) -> Re let mut qconnect = crate::qconnect::start( booted.runtime.clone(), booted.shared.clone(), + qconnect_authority.clone(), &roots, quality_cell.clone(), report_notify, @@ -407,7 +434,11 @@ fn revalidate_alsa_hardware_volume( } } -async fn boot(roots: &ProfileRoots, cfg: &QbzdConfig, warn_count: usize) -> Result { +async fn boot( + roots: &ProfileRoots, + cfg: &QbzdConfig, + warn_count: usize, +) -> Result { // 6.+7. stores + runtime composition. The two substitutions (01 §2.2): // - with_audio_settings, NOT AppRuntime::new (which hardcodes the // desktop-global AudioSettingsStore — shell.rs:87-101); @@ -527,9 +558,13 @@ fn build_driver_deps( report_notify: Arc, edge_notify: Arc, bus: tokio::sync::broadcast::Sender, + qconnect_authority: Arc, ) -> DriverDeps { let latch_shared = shared.clone(); let tick_shared = shared; + let qconnect_authority_allowed = Arc::clone(&qconnect_authority); + let qconnect_authority_observe = Arc::clone(&qconnect_authority); + let qconnect_authority_readmit = qconnect_authority; DriverDeps { quality: Arc::new(move || { quality_cell @@ -537,6 +572,32 @@ fn build_driver_deps( .map(|q| *q) .unwrap_or(qbz_models::Quality::UltraHiRes) }), + owner_actions_allowed: Arc::new(move || qconnect_authority_allowed.owner_actions_allowed()), + observe_authority: Arc::new(move || { + match qconnect_authority_observe.observe_owner_authority() { + crate::qconnect::authority::OwnerAuthorityObservation::Owner { + token, + permit, + } => qbz_app::playback_driver::DriverAuthorityObservation::Owner( + qbz_app::playback_driver::DriverOwnerAdmission::new( + qbz_app::playback_driver::DriverOwnerToken::new(token), + qbz_app::playback_driver::DriverActionPermit::new(permit), + ), + ), + crate::qconnect::authority::OwnerAuthorityObservation::Delegated => { + qbz_app::playback_driver::DriverAuthorityObservation::Delegated + } + crate::qconnect::authority::OwnerAuthorityObservation::Fenced => { + qbz_app::playback_driver::DriverAuthorityObservation::Fenced + } + } + }), + readmit_owner_action: Arc::new(move |token| { + let token = token.downcast_ref::()?; + qconnect_authority_readmit + .try_owner_action_permit_exact(*token) + .map(qbz_app::playback_driver::DriverActionPermit::new) + }), // T10: signal the report scheduler on every ReportEdge. `notify_one` // stores a single permit if the scheduler is mid-report, so no edge is // lost and rapid edges coalesce into one report. @@ -580,6 +641,7 @@ fn build_driver_deps( fn spawn_queue_persist( runtime: Arc>, mut rx: broadcast::Receiver, + qconnect_authority: Arc, ) -> JoinHandle<()> { use tokio::sync::broadcast::error::RecvError; const DEBOUNCE: std::time::Duration = std::time::Duration::from_secs(2); @@ -608,6 +670,10 @@ fn spawn_queue_persist( } } } + let Some(_owner_permit) = qconnect_authority.try_owner_action_permit() else { + log::debug!("[qbzd] queue-persist: skipped delegated QConnect queue"); + continue; + }; playback_driver::save_session_now(runtime.as_ref()).await; log::debug!("[qbzd] queue-persist: session flushed after QueueUpdated burst"); } @@ -898,10 +964,10 @@ async fn wait_for_signal() { // reinit/reload narrative is composed CLIENT-side from the CLI's own copy of // the Apply-ladder classification, never carried on the wire). -/// The single entry point the HTTP route calls. Order matters only at the -/// margin (independent domains): audio/quality/qconnect-KV first, credentials -/// last, so a login/logout settles the auth state before QConnect decides -/// whether to (re)connect against it. +/// The single entry point the HTTP route calls. Audio/quality are independent; +/// credentials are reconciled next so their branch can retire LAN/delegated +/// QConnect before mutating owner auth, and QConnect KV reconciliation runs last +/// so an enabled service reconnects only against the settled credential state. pub(crate) async fn reload(state: &crate::api::ApiState) { reload_audio(state); reload_quality(state); @@ -922,6 +988,22 @@ pub(crate) async fn reload(state: &crate::api::ApiState) { /// 87-94`, per-key classification `:877-967,1134-1290`; 03-setup-tui.md §4.3 /// lists the same 9 fields). pub(crate) fn reload_audio(state: &crate::api::ApiState) { + // Audio reload is an owner-side live-player action. Keep this permit only + // for the audio phase: `reload_credentials`/`reload_qconnect` below may + // intentionally tear down delegated authority and must be free to acquire + // and drain their own transition fence. + let _owner_action = match state.qconnect_control.get() { + None => None, // boot window: no delegated runtime can exist yet + Some(qconnect) => { + let Some(permit) = qconnect.try_owner_action_permit() else { + log::info!( + "[reload] live audio settings not applied while QConnect owns playback; retry reload after owner authority returns" + ); + return; + }; + Some(permit) + } + }; let (fresh, hardware_volume) = match revalidate_alsa_hardware_volume(&state.audio) { Ok(result) => result, Err(e) => { @@ -1057,8 +1139,8 @@ pub(crate) fn decide_credential_action( /// Re-read the credential file and reconcile the live session against it (02 /// §3.3.17: "absent → NeedsAuth transition; new → session restore"; taxonomy /// shared with boot, §6.2). `qconnect` is `None` only in the brief boot window -/// before step 12 populates the cell — the teardown branch just skips the -/// QConnect disconnect then (there is no session for it to hold yet). +/// before step 12 populates the cell — credential-change teardown is skipped +/// only then, because no LAN/coordinator/runtime exists yet. pub(crate) async fn reload_credentials( runtime: &Arc>, shared: &Arc>, @@ -1082,7 +1164,14 @@ pub(crate) async fn reload_credentials( CredentialAction::EnterNeedsAuth => { log::info!("[reload] credential file cleared — tearing the session down (NeedsAuth)"); if let Some(qc) = qconnect { - let _ = qc.disconnect().await; + if let Err(e) = qc.disconnect_for_credentials_change().await { + // Fail closed: owner logout/deactivation must never race a + // surviving LAN candidate or delegated authority. + log::warn!( + "[reload] QConnect credential-change teardown failed; keeping current auth: {e}" + ); + return; + } } let _ = runtime.core().stop(); let _ = runtime.core().logout().await; @@ -1090,6 +1179,16 @@ pub(crate) async fn reload_credentials( set_needs_auth(shared, None); } CredentialAction::Apply(token) => { + if let Some(qc) = qconnect { + if let Err(e) = qc.disconnect_for_credentials_change().await { + // Do not install/account-switch the owner token until LAN, + // candidate validation, and delegated authority are gone. + log::warn!( + "[reload] QConnect credential-change teardown failed; token not applied: {e}" + ); + return; + } + } qbz_log::register_secret(token.clone()); match runtime.core().login_with_token(&token).await { Ok(session) => { diff --git a/crates/qbzd/src/events_bridge.rs b/crates/qbzd/src/events_bridge.rs index e25251dfc..ad4cc17f3 100644 --- a/crates/qbzd/src/events_bridge.rs +++ b/crates/qbzd/src/events_bridge.rs @@ -25,6 +25,9 @@ use tokio::sync::{broadcast, Notify}; use tokio::task::JoinHandle; use crate::adapter::DaemonAdapter; +use crate::qconnect::authority::{ + AuthorityCell, OwnerAuthorityObservation, OwnerAuthorityToken, +}; type Runtime = Arc>; @@ -32,6 +35,17 @@ type Runtime = Arc>; /// a stop cleared the track id, which suppresses `ReportEdge`). const FALLBACK_POLL: Duration = Duration::from_secs(2); +/// One playback observation plus the exact owner authority that produced it. +/// +/// `None` is deliberately meaningful: the observation began while delegated +/// authority (or a handoff fence) was active and owner-only consumers must +/// treat it as a handoff, even if owner authority is restored before delivery. +#[derive(Clone, Debug)] +pub struct AuthorityStampedEvent { + pub event: CoreEvent, + pub owner_token: Option, +} + /// Spawn the bridge task. Emits, deduped against the last published values: /// * `TrackStarted` on a track-id change while playing (with the queue's /// current-track metadata), @@ -39,17 +53,53 @@ const FALLBACK_POLL: Duration = Duration::from_secs(2); /// * `PositionUpdated` on every wake while playing (~2 s cadence, the /// scrobbler's timing source and the MPRIS progress feed), /// * `VolumeChanged` on a volume change. -pub fn spawn(runtime: &Runtime, bus: broadcast::Sender, edge: Arc) -> JoinHandle<()> { +pub fn spawn( + runtime: &Runtime, + bus: broadcast::Sender, + owner_bus: broadcast::Sender, + edge: Arc, + authority: Arc, +) -> JoinHandle<()> { let weak: Weak> = Arc::downgrade(runtime); tokio::spawn(async move { let mut last_track_id: u64 = 0; let mut last_state: Option = None; let mut last_volume: Option = None; + let mut last_owner_token: Option = None; loop { // Wake on a driver edge, or fall back to a slow poll so a // track-id-clearing stop still surfaces as a transition. let _ = tokio::time::timeout(FALLBACK_POLL, edge.notified()).await; + // The observation is admitted before the first player/queue read. + // Keeping the permit alive through publication makes a handoff wait + // for this complete read -> event transaction. A delegated/fenced + // observation remains stamped `None` even if an owner is installed + // while the async queue lookup below is pending. + let (owner_token, owner_permit) = match authority.observe_owner_authority() { + OwnerAuthorityObservation::Owner { token, permit } => { + (Some(token), Some(permit)) + } + OwnerAuthorityObservation::Delegated => (None, None), + OwnerAuthorityObservation::Fenced => { + // A candidate activation can hold the fence while the + // installed authority is still owner and may ultimately + // fail. Do not turn that transient pause into a fabricated + // guest edge or disturb the dedup baseline. + continue; + } + }; + + // Authority identity is part of the dedup key. In particular, a + // guest -> owner restore of the same track/state must publish fresh + // owner-stamped edges rather than leaving consumers on guest data. + if owner_token != last_owner_token { + last_track_id = 0; + last_state = None; + last_volume = None; + last_owner_token = owner_token; + } + let Some(rt) = weak.upgrade() else { return }; let core = rt.core(); let player = core.player(); @@ -62,10 +112,15 @@ pub fn spawn(runtime: &Runtime, bus: broadcast::Sender, edge: Arc, edge: Arc, edge: Arc 0.001) { - let _ = bus.send(CoreEvent::VolumeChanged { volume: ev.volume }); + publish( + &bus, + &owner_bus, + owner_token, + CoreEvent::VolumeChanged { volume: ev.volume }, + ); last_volume = Some(ev.volume); } + // Owner observations remain drain-visible through every async read + // and both bus publications in this wake. + drop(owner_permit); } }) } + +fn publish( + bus: &broadcast::Sender, + owner_bus: &broadcast::Sender, + owner_token: Option, + event: CoreEvent, +) { + let _ = owner_bus.send(AuthorityStampedEvent { + event: event.clone(), + owner_token, + }); + let _ = bus.send(event); +} diff --git a/crates/qbzd/src/listen_log_engine.rs b/crates/qbzd/src/listen_log_engine.rs index 3c7a6cfbe..07cd50af4 100644 --- a/crates/qbzd/src/listen_log_engine.rs +++ b/crates/qbzd/src/listen_log_engine.rs @@ -1,6 +1,6 @@ // crates/qbzd/src/listen_log_engine.rs — the daemon's listen log. // -// A sibling of `scrobble_engine` on the same CoreEvent bus, WITHOUT its +// A sibling of `scrobble_engine` on the same authority-stamped playback bus, WITHOUT its // `settings.enabled` gate: the scrobbler's switch is about sending plays // elsewhere; the log is local and on by default (its own switch lives in // `listen_meta.paused`, shared with the desktop's Settings toggle). Until @@ -21,7 +21,9 @@ use qbz_models::{CoreEvent, PlaybackState}; use tokio::sync::broadcast; use tokio::task::JoinHandle; +use crate::events_bridge::AuthorityStampedEvent; use crate::paths::ProfileRoots; +use crate::qconnect::authority::{AuthorityCell, OwnerAuthorityToken}; /// The bound logger, so the daemon's shutdown path can close the open row /// after aborting the task. @@ -44,7 +46,11 @@ impl ListenLogTask { /// Open the daemon-root store (`/listen/listen_log.db`, origin /// `qbzd:`) and subscribe. A failed open logs and yields an inert /// task — the daemon must boot without a history file. -pub async fn spawn(roots: &ProfileRoots, rx: broadcast::Receiver) -> ListenLogTask { +pub async fn spawn( + roots: &ProfileRoots, + rx: broadcast::Receiver, + authority: Arc, +) -> ListenLogTask { let hostname = qbz_app::settings::bundle::hostname(); let logger = match ListenLogger::open(roots.data.clone(), Origin::Daemon { hostname }).await { Ok(l) => l, @@ -56,37 +62,221 @@ pub async fn spawn(roots: &ProfileRoots, rx: broadcast::Receiver) -> }; } }; - let handle = tokio::spawn(run(Arc::clone(&logger), rx)); + let handle = tokio::spawn(run(Arc::clone(&logger), rx, authority)); ListenLogTask { handle, logger: Some(logger), } } -async fn run(logger: Arc, mut rx: broadcast::Receiver) { +async fn run( + logger: Arc, + mut rx: broadcast::Receiver, + authority: Arc, +) { use broadcast::error::RecvError; + let mut active_owner_token: Option = None; loop { match rx.recv().await { - Ok(CoreEvent::TrackStarted { track, .. }) => { - let meta = meta_from_queue_track(&track, None, None, None); - logger.track_started(meta, true).await; + Ok(event) => { + handle_event(&logger, &authority, &mut active_owner_token, event).await } - Ok(CoreEvent::PositionUpdated { position_secs, .. }) => { - logger.tick(position_secs * 1_000, true).await; - } - Ok(CoreEvent::PlaybackStateChanged { state }) => { - if state == PlaybackState::Stopped { - logger.stopped(true).await; - } - } - Ok(_) => {} Err(RecvError::Lagged(n)) => { // Position ticks were dropped: the accumulator simply misses // them (a gap > 5 s credits nothing), which is the honest - // outcome — never extrapolate. + // outcome — never extrapolate. Also close the open row: the + // missing span may contain an authority handoff. log::debug!("[listen-log] bus lagged by {n} events"); + active_owner_token = None; + logger.handoff().await; } Err(RecvError::Closed) => return, } } } + +async fn handle_event( + logger: &ListenLogger, + authority: &Arc, + active_owner_token: &mut Option, + stamped: AuthorityStampedEvent, +) { + let Some(owner_token) = stamped.owner_token else { + *active_owner_token = None; + logger.handoff().await; + return; + }; + let Some(permit) = authority + .wait_for_exact_owner_action_permit(owner_token) + .await + else { + // A stale old-owner delivery must not close a row already opened by a + // newer owner generation. Close only the row that this rejected event + // could have belonged to (or an untracked open row). + if active_owner_token + .as_ref() + .is_none_or(|current| *current == owner_token) + { + *active_owner_token = None; + logger.handoff().await; + } + return; + }; + + // A later owner generation is a real authority boundary even when the bus + // lagged over the intervening guest event. Never let the restored owner + // continue accumulating into the pre-handoff row. + if active_owner_token.is_some_and(|current| current != owner_token) { + logger.handoff().await; + } + *active_owner_token = Some(owner_token); + + match stamped.event { + CoreEvent::TrackStarted { track, .. } => { + let meta = meta_from_queue_track(&track, None, None, None); + logger.track_started(meta, true).await; + } + CoreEvent::PositionUpdated { position_secs, .. } => { + logger.tick(position_secs * 1_000, true).await; + } + CoreEvent::PlaybackStateChanged { state } if state == PlaybackState::Stopped => { + logger.stopped(true).await; + } + _ => {} + } + // The exact admission covers all logger awaits and their SQLite writes. + drop(permit); +} + +#[cfg(test)] +mod tests { + use qbz_app::listen_log::{EndReason, Origin}; + use qbz_models::QueueTrack; + + use crate::qconnect::authority::AuthorityOrigin; + + use super::*; + + fn track(id: u64) -> QueueTrack { + QueueTrack { + id, + title: format!("Track {id}"), + version: None, + artist: "Artist".into(), + album: "Album".into(), + album_version: None, + duration_secs: 180, + artwork_url: None, + hires: false, + bit_depth: None, + sample_rate: None, + is_local: false, + album_id: None, + artist_id: None, + streamable: true, + source: Some("qobuz".into()), + parental_warning: false, + source_item_id_hint: None, + context_kind: None, + context_id: None, + isrc: None, + recording_mbid: None, + } + } + + #[tokio::test] + async fn guest_handoff_closes_owner_and_ignores_guest_playback() { + let dir = tempfile::tempdir().unwrap(); + let logger = ListenLogger::open(dir.path().to_path_buf(), Origin::Install) + .await + .unwrap(); + let authority = Arc::new(AuthorityCell::new()); + let owner = authority.reserve(AuthorityOrigin::Owner); + assert!(authority.install(owner)); + let (owner_token, permit) = authority + .try_owner_action_permit_observed() + .expect("owner observation"); + drop(permit); + let mut active_owner_token = None; + + handle_event( + &logger, + &authority, + &mut active_owner_token, + AuthorityStampedEvent { + event: CoreEvent::TrackStarted { + track: track(1), + position_secs: 0, + }, + owner_token: Some(owner_token), + }, + ) + .await; + handle_event( + &logger, + &authority, + &mut active_owner_token, + AuthorityStampedEvent { + event: CoreEvent::PositionUpdated { + position_secs: 1, + duration_secs: 180, + }, + owner_token: Some(owner_token), + }, + ) + .await; + + let guest = authority.reserve(AuthorityOrigin::Delegated { generation: 9 }); + assert!(authority.install(guest)); + handle_event( + &logger, + &authority, + &mut active_owner_token, + AuthorityStampedEvent { + event: CoreEvent::TrackStarted { + track: track(2), + position_secs: 0, + }, + owner_token: None, + }, + ) + .await; + handle_event( + &logger, + &authority, + &mut active_owner_token, + AuthorityStampedEvent { + event: CoreEvent::PositionUpdated { + position_secs: 30, + duration_secs: 180, + }, + owner_token: None, + }, + ) + .await; + + let restored_owner = authority.reserve(AuthorityOrigin::Owner); + assert!(authority.install(restored_owner)); + handle_event( + &logger, + &authority, + &mut active_owner_token, + AuthorityStampedEvent { + event: CoreEvent::TrackStarted { + track: track(3), + position_secs: 0, + }, + // A late event from the pre-handoff owner must not be promoted + // merely because owner authority is available again. + owner_token: Some(owner_token), + }, + ) + .await; + + let rows = logger.rows().await.unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].source_item_id, "1"); + assert_eq!(rows[0].end_reason, Some(EndReason::Handoff)); + assert!(!logger.has_open_row()); + } +} diff --git a/crates/qbzd/src/mpris.rs b/crates/qbzd/src/mpris.rs index 6c2cf1398..97a16a0f2 100644 --- a/crates/qbzd/src/mpris.rs +++ b/crates/qbzd/src/mpris.rs @@ -32,6 +32,7 @@ use tokio::task::JoinHandle; use crate::adapter::DaemonAdapter; use crate::paths::ProfileRoots; +use crate::qconnect::authority::AuthorityCell; type Runtime = Arc>; @@ -59,7 +60,10 @@ impl MprisHandle { /// `qbzd settings set playback.mpris` write. fn enabled(roots: &ProfileRoots) -> bool { if let Ok(v) = std::env::var("QBZD_MPRIS") { - return !matches!(v.trim().to_ascii_lowercase().as_str(), "0" | "false" | "off" | "no"); + return !matches!( + v.trim().to_ascii_lowercase().as_str(), + "0" | "false" | "off" | "no" + ); } qbz_app::settings::daemon_prefs::load_at(&roots.data).mpris_enabled } @@ -71,6 +75,7 @@ pub fn spawn( roots: ProfileRoots, mut bus: broadcast::Receiver, handle: Handle, + authority: Arc, ) -> Option { if !enabled(&roots) { log::info!("[mpris] disabled (playback.mpris / QBZD_MPRIS)"); @@ -81,13 +86,14 @@ pub fn spawn( // integration never keeps the runtime alive. let weak: Weak> = Arc::downgrade(runtime); let cb_handle = handle.clone(); + let cb_authority = Arc::clone(&authority); // Headless: the daemon owns no window, so there is no HWND to give. // On Linux, which is the only place qbzd ships, MPRIS keys off the D-Bus // name and ignores this entirely. let integration: Arc = Arc::from(qbz_media_controls::spawn( move |ev| { if let Some(rt) = weak.upgrade() { - handle_media_event(&rt, &roots, &cb_handle, ev); + handle_media_event(&rt, &roots, &cb_handle, &cb_authority, ev); } }, qbz_media_controls::NativeWindow::default(), @@ -123,7 +129,10 @@ pub fn spawn( loop { match bus.recv().await { - Ok(CoreEvent::TrackStarted { track, position_secs }) => { + Ok(CoreEvent::TrackStarted { + track, + position_secs, + }) => { updater_integ.set_metadata(&track_meta(&track)); last = PlaybackStatus::Playing; updater_integ.set_playback(last, Some(Duration::from_secs(position_secs))); @@ -146,7 +155,10 @@ pub fn spawn( } }); - Some(MprisHandle { integration, updater }) + Some(MprisHandle { + integration, + updater, + }) } // ============================ inbound ============================ @@ -155,16 +167,31 @@ pub fn spawn( /// mpris-server (D-Bus) thread — NOT a tokio worker — so the sync core commands /// are called directly; the async advance ritual is spawned fire-and-forget so /// the D-Bus thread never blocks on a network resolve. Time values are micros. -fn handle_media_event(rt: &Runtime, roots: &ProfileRoots, handle: &Handle, ev: MediaEvent) { +fn handle_media_event( + rt: &Runtime, + roots: &ProfileRoots, + handle: &Handle, + authority: &Arc, + ev: MediaEvent, +) { let core = rt.core(); match ev { MediaEvent::Play => { + let Some(_transport_permit) = authority.try_transport_action_permit() else { + return; + }; let _ = core.resume(); } MediaEvent::Pause => { + let Some(_transport_permit) = authority.try_transport_action_permit() else { + return; + }; let _ = core.pause(); } MediaEvent::Toggle => { + let Some(_transport_permit) = authority.try_transport_action_permit() else { + return; + }; let player = core.player(); if player.get_playback_event().is_playing { let _ = core.pause(); @@ -173,31 +200,59 @@ fn handle_media_event(rt: &Runtime, roots: &ProfileRoots, handle: &Handle, ev: M } } MediaEvent::Stop => { + let Some(_transport_permit) = authority.try_transport_action_permit() else { + return; + }; let _ = core.stop(); } - MediaEvent::Next => spawn_advance(rt, roots, handle, true), - MediaEvent::Previous => spawn_advance(rt, roots, handle, false), + MediaEvent::Next => { + if let Some(permit) = authority.try_owner_action_permit() { + spawn_advance(rt, roots, handle, permit, true); + } + } + MediaEvent::Previous => { + if let Some(permit) = authority.try_owner_action_permit() { + spawn_advance(rt, roots, handle, permit, false); + } + } MediaEvent::SeekBy(micros) => { + let Some(_transport_permit) = authority.try_transport_action_permit() else { + return; + }; let player = core.player(); if player.is_dsd_direct_active() { return; } let ev = player.get_playback_event(); let target = (ev.position as i64 + micros / 1_000_000).max(0) as u64; - let clamped = if ev.duration > 0 { target.min(ev.duration) } else { target }; + let clamped = if ev.duration > 0 { + target.min(ev.duration) + } else { + target + }; let _ = core.seek(clamped); } MediaEvent::SetPosition(micros) => { + let Some(_transport_permit) = authority.try_transport_action_permit() else { + return; + }; let player = core.player(); if player.is_dsd_direct_active() { return; } let ev = player.get_playback_event(); let target = (micros.max(0) as u64) / 1_000_000; - let clamped = if ev.duration > 0 { target.min(ev.duration) } else { target }; + let clamped = if ev.duration > 0 { + target.min(ev.duration) + } else { + target + }; let _ = core.seek(clamped); } MediaEvent::SetVolume(vol) => { + let Some(_transport_permit) = authority.try_transport_action_permit() else { + return; + }; let player = core.player(); if !player.is_dsd_direct_active() { let _ = core.set_volume((vol as f32).clamp(0.0, 1.0)); @@ -212,12 +267,19 @@ fn handle_media_event(rt: &Runtime, roots: &ProfileRoots, handle: &Handle, ev: M /// Fire-and-forget the FULL advance ritual (skip-walk → play → prefetch → /// persist) off the D-Bus thread, at the daemon's persisted streaming quality /// (the same key the driver seeds at boot). -fn spawn_advance(rt: &Runtime, roots: &ProfileRoots, handle: &Handle, forward: bool) { +fn spawn_advance( + rt: &Runtime, + roots: &ProfileRoots, + handle: &Handle, + permit: crate::qconnect::authority::AuthorityActionPermit, + forward: bool, +) { let rt = rt.clone(); let quality = qbz_app::playback_driver::quality_from_key( &qbz_app::settings::daemon_prefs::load_at(&roots.data).streaming_quality, ); handle.spawn(async move { + let _permit = permit; let _ = qbz_app::playback_driver::advance_and_play(rt.as_ref(), quality, forward).await; }); } @@ -264,13 +326,19 @@ mod tests { // classification the getter uses. for v in ["0", "false", "off", "no", "FALSE", " Off "] { assert!( - matches!(v.trim().to_ascii_lowercase().as_str(), "0" | "false" | "off" | "no"), + matches!( + v.trim().to_ascii_lowercase().as_str(), + "0" | "false" | "off" | "no" + ), "{v:?} should read as disabled" ); } for v in ["1", "true", "on", "yes", "anything"] { assert!( - !matches!(v.trim().to_ascii_lowercase().as_str(), "0" | "false" | "off" | "no"), + !matches!( + v.trim().to_ascii_lowercase().as_str(), + "0" | "false" | "off" | "no" + ), "{v:?} should read as enabled" ); } diff --git a/crates/qbzd/src/qconnect/authority.rs b/crates/qbzd/src/qconnect/authority.rs new file mode 100644 index 000000000..43224761d --- /dev/null +++ b/crates/qbzd/src/qconnect/authority.rs @@ -0,0 +1,9 @@ +//! Daemon compatibility shim for the shared QConnect authority barrier. +//! +//! The implementation lives in `qconnect-app` so every frontend can use the +//! exact same stamps, action permits, and handoff drain boundary. + +pub use qconnect_app::{ + AuthorityActionPermit, AuthorityCell, AuthorityOrigin, AuthorityStamp, + OwnerAuthorityObservation, OwnerAuthorityToken, +}; diff --git a/crates/qbzd/src/qconnect/delegation.rs b/crates/qbzd/src/qconnect/delegation.rs new file mode 100644 index 000000000..9622997b0 --- /dev/null +++ b/crates/qbzd/src/qconnect/delegation.rs @@ -0,0 +1,1833 @@ +//! qbzd host for the shared transactional QConnect delegation coordinator. +//! +//! Candidate API/QWS work is completed against isolated contexts while the +//! installed runtime remains authoritative. The synchronous commit methods do +//! only a short stamped runtime swap; teardown, queue restoration and cloud +//! bootstrap happen afterwards. + +use std::collections::VecDeque; +use std::future::Future; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering}; +use std::sync::{Arc, Mutex as StdMutex, OnceLock}; +use std::time::Duration; + +use async_trait::async_trait; +use qbz_app::shell::AppRuntime; +use qbz_core::QueueAuthoritySnapshot; +use qbz_models::{CoreEvent, Quality}; +use qbz_qobuz::{DelegatedApiConfig, DelegatedApiEndpoint, DelegatedQobuzClient, QobuzClient}; +use qconnect_app::renderer::PLAYING_STATE_STOPPED; +use qconnect_app::{ + CommitRejected, DelegationCancellation, DelegationCoordinator, DelegationErrorCode, + DelegationHost, QconnectApp, QconnectAppEvent, QconnectEventSink, QconnectLifecycleState, + QconnectRemoteSyncState, QconnectSessionState, RendererBufferState, RendererReport, + RendererReportType, RestoreReason, SessionLoopHost, JOIN_SESSION_REASON_CONTROLLER_REQUEST, +}; +use qconnect_lan::{HandoffCandidate, LanProjection}; +use qconnect_protocol::RendererCommandType; +use qconnect_transport_ws::{NativeWsTransport, TransportEvent, WsTransportConfig}; +use tokio::sync::{broadcast, oneshot, watch, Mutex as AsyncMutex, OwnedMutexGuard}; +use uuid::Uuid; +use zeroize::Zeroizing; + +use crate::adapter::DaemonAdapter; +use crate::state::DaemonShared; + +use super::authority::{AuthorityCell, AuthorityOrigin, AuthorityStamp}; +use super::engine::{DaemonRendererEngine, VolumeMode}; +use super::lan::DaemonLanProjectionSlot; +use super::session::{bootstrap_prepared_owner_presence, DaemonSessionLoopHost}; +use super::sink::{DaemonEventSink, DaemonQconnectApp}; +use super::transport::{default_qconnect_device_info_with_name, resolve_transport_config}; +use super::{ + lock_inner, update_lifecycle_state_if_running, DaemonQconnectInner, DaemonQconnectRuntime, +}; + +type Runtime = Arc>; +type SharedState = Arc>; +type OwnerRestoreResult = Result<(), &'static str>; +pub type DaemonDelegationCoordinator = DelegationCoordinator; + +const PREFLIGHT_BUFFER_EVENTS: usize = 256; +const PREFLIGHT_BUFFER_BYTES: usize = 256 * 1024; +const DELEGATED_REJOIN_SESSION_TIMEOUT: Duration = Duration::from_secs(15); +const SHUTDOWN_RESTORE_OWNER: u8 = 0; +const SHUTDOWN_DISCARD_OWNER: u8 = 1; +static NEXT_OWNER_RESTORE_TASK_ID: AtomicU64 = AtomicU64::new(1); + +#[derive(Clone)] +struct OwnerRestoreTask { + id: u64, + abort: tokio::task::AbortHandle, + mutation_finished: Arc, + completion: watch::Receiver>, +} + +impl OwnerRestoreTask { + fn is_pending(&self) -> bool { + self.completion.borrow().is_none() + } +} + +#[derive(Default)] +struct RejoinWatchdogGeneration(AtomicU64); + +impl RejoinWatchdogGeneration { + fn advance(&self) -> u64 { + self.0.fetch_add(1, Ordering::AcqRel).wrapping_add(1) + } + + /// Claims an armed generation exactly once. A reconnect, successful + /// establishment, runtime drop, or replacement arm advances the epoch and + /// makes the stale deadline harmless. + fn claim(&self, expected: u64) -> bool { + self.0 + .compare_exchange( + expected, + expected.wrapping_add(1), + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + } +} + +struct DelegatedRejoinWatchdog { + generation: Arc, + task: Option>, +} + +impl DelegatedRejoinWatchdog { + fn new() -> Self { + Self { + generation: Arc::new(RejoinWatchdogGeneration::default()), + task: None, + } + } + + fn arm( + &mut self, + authority: Arc, + stamp: AuthorityStamp, + delegation_generation: u64, + coordinator: Option, + ) { + // Advance before aborting so even a task already waking at the + // deadline loses its compare-exchange against this newer rejoin. + let watchdog_generation = self.generation.advance(); + if let Some(task) = self.task.take() { + task.abort(); + } + let generation = Arc::clone(&self.generation); + self.task = Some(tokio::spawn(async move { + tokio::time::sleep(DELEGATED_REJOIN_SESSION_TIMEOUT).await; + if !generation.claim(watchdog_generation) + || stamp.origin() + != (AuthorityOrigin::Delegated { + generation: delegation_generation, + }) + || !authority.is_current(stamp) + { + return; + } + request_restore(coordinator.as_ref(), delegation_generation).await; + })); + } + + fn cancel(&mut self) { + // Invalidate first: abort is cooperative and may race a waking task. + self.generation.advance(); + if let Some(task) = self.task.take() { + task.abort(); + } + } +} + +impl Drop for DelegatedRejoinWatchdog { + fn drop(&mut self) { + self.cancel(); + } +} + +#[derive(Clone)] +struct OwnerSnapshot { + queue: QueueAuthoritySnapshot, + volume: f32, + playback: OwnerPlaybackSnapshot, +} + +#[derive(Clone, Copy)] +struct OwnerPlaybackSnapshot { + track_id: u64, + position_secs: u64, + was_playing: bool, + had_loaded_audio: bool, +} + +struct OwnerActionFence { + authority: Arc, +} + +impl OwnerActionFence { + fn acquire(authority: Arc) -> Self { + authority.suspend_owner_actions(); + Self { authority } + } +} + +impl Drop for OwnerActionFence { + fn drop(&mut self) { + self.authority.resume_owner_actions(); + } +} + +struct PreparedCommon { + app: Arc, + sink: Arc, + config: WsTransportConfig, + sync_state: Arc>, + receiver: broadcast::Receiver, + buffered: VecDeque, + buffered_bytes: usize, + stamp: AuthorityStamp, + expected_current: AuthorityStamp, + volume_mode: VolumeMode, + custom_name: Option, + /// Captured from owner SESSION_STATE during preparation. `prepare_owner` + /// cannot return until the matching SessionEstablished signal also arrives, + /// and this value stays private until `commit_owner` installs that runtime. + confirmed_owner_session_id: Option>, +} + +pub struct PreparedDelegation { + common: PreparedCommon, + session_id: Zeroizing, + become_active: bool, + owner_snapshot: Option, + transition_guard: Option>, + transition_fence: Option, +} + +pub struct PreparedOwner { + common: PreparedCommon, + transition_guard: OwnedMutexGuard<()>, + transition_fence: OwnerActionFence, +} + +enum PendingActivationKind { + Delegated, + Owner { + volume: f32, + playback: OwnerPlaybackSnapshot, + }, +} + +/// Cancellation-safe release of a freshly installed runtime. Normal retirement +/// stops the old transport first; if the coordinator's cleanup timeout drops +/// that future, `Drop` still stops stale audio and releases the current loop. +struct PendingActivation { + start: Option>, + runtime: Runtime, + authority: Arc, + stamp: AuthorityStamp, + kind: PendingActivationKind, + transition_guard: Option>, + transition_fence: Option, + owner_restore_task: Arc>>, + owner_quality: Quality, +} + +/// The final release edge of an installed authority transition. +/// +/// Owner rollback moves this guard into its tracked playback-restoration task, +/// keeping both ordinary owner actions and the next authority transition out +/// until the restored stream is stable. `Drop` is deliberately sufficient: a +/// coordinator cleanup timeout or task abort still wakes the installed event +/// loop and releases both gates. +struct DeferredActivationRelease { + start: Option>, + transition_fence: Option, + transition_guard: Option>, +} + +impl DeferredActivationRelease { + fn new( + start: Option>, + transition_fence: Option, + transition_guard: Option>, + ) -> Self { + Self { + start, + transition_fence, + transition_guard, + } + } + + fn release(&mut self) { + // Admission opens before the prepared loop wakes so buffered commands + // cannot observe the transition fence and disappear as rejected work. + self.transition_fence.take(); + if let Some(start) = self.start.take() { + let _ = start.send(()); + } + self.transition_guard.take(); + } +} + +impl Drop for DeferredActivationRelease { + fn drop(&mut self) { + self.release(); + } +} + +impl PendingActivation { + fn release(&mut self) { + let Some(start) = self.start.take() else { + return; + }; + if !self.authority.is_current(self.stamp) { + self.transition_fence.take(); + self.transition_guard.take(); + return; + } + let _ = self.runtime.core().stop(); + if let PendingActivationKind::Owner { volume, .. } = &self.kind { + if let Err(error) = self.runtime.core().set_volume(*volume) { + log::warn!("[QConnect] failed to restore owner volume: {error}"); + } + } + if let PendingActivationKind::Owner { playback, .. } = &self.kind { + let release = DeferredActivationRelease::new( + Some(start), + self.transition_fence.take(), + self.transition_guard.take(), + ); + schedule_owner_playback_restore( + Arc::clone(&self.runtime), + Arc::clone(&self.authority), + self.stamp, + *playback, + self.owner_quality, + Arc::clone(&self.owner_restore_task), + release, + ); + } else { + let mut release = DeferredActivationRelease::new( + Some(start), + self.transition_fence.take(), + self.transition_guard.take(), + ); + release.release(); + } + } +} + +impl Drop for PendingActivation { + fn drop(&mut self) { + self.release(); + } +} + +pub struct RetiredAuthority { + runtime: Option, + activation: Option, +} + +/// Runtime-owned host. It contains no delegated credential material; prepared +/// candidates own and scrub that material until a successful stamped commit. +pub struct DaemonDelegationHost { + runtime: Runtime, + inner: Arc>, + shared: SharedState, + settings_db: std::path::PathBuf, + custom_device_name: Arc>>, + quality_cap: Arc>, + authority: Arc, + coordinator: OnceLock, + transition_gate: Arc>, + owner_snapshot: StdMutex>, + owner_restore_task: Arc>>, + projection: DaemonLanProjectionSlot, + shutdown_mode: AtomicU8, +} + +impl DaemonDelegationHost { + #[allow(clippy::too_many_arguments)] + pub fn new( + runtime: Runtime, + inner: Arc>, + shared: SharedState, + settings_db: std::path::PathBuf, + custom_device_name: Arc>>, + quality_cap: Arc>, + authority: Arc, + ) -> Self { + Self { + runtime, + inner, + shared, + settings_db, + custom_device_name, + quality_cap, + authority, + coordinator: OnceLock::new(), + transition_gate: Arc::new(AsyncMutex::new(())), + owner_snapshot: StdMutex::new(None), + owner_restore_task: Arc::new(StdMutex::new(None)), + projection: DaemonLanProjectionSlot::default(), + shutdown_mode: AtomicU8::new(SHUTDOWN_RESTORE_OWNER), + } + } + + pub fn install_coordinator(&self, coordinator: DaemonDelegationCoordinator) -> bool { + self.coordinator.set(coordinator).is_ok() + } + + pub fn attach_projection(&self, projection: LanProjection) { + self.projection.attach(&self.authority, projection); + } + + pub fn detach_projection(&self) { + self.projection.detach(); + } + + pub fn projection_slot(&self) -> DaemonLanProjectionSlot { + self.projection.clone() + } + + pub fn current_projected_session_id(&self, stamp: AuthorityStamp) -> Option { + self.projection.current_session_id(&self.authority, stamp) + } + + pub fn set_shutdown_restore_owner(&self, restore: bool) { + self.shutdown_mode.store( + if restore { + SHUTDOWN_RESTORE_OWNER + } else { + SHUTDOWN_DISCARD_OWNER + }, + Ordering::Release, + ); + } + + fn coordinator(&self) -> Option { + self.coordinator.get().cloned() + } + + pub async fn await_owner_playback_restore(&self) -> Result<(), &'static str> { + // Clone the observation handles but leave the task in the host slot. + // If this future is itself cancelled, a later caller can still await + // the same restoration instead of silently detaching its JoinHandle. + let task = recover_lock(&self.owner_restore_task).as_ref().cloned(); + let Some(mut task) = task else { + return Ok(()); + }; + match tokio::time::timeout( + Duration::from_secs(31), + wait_for_owner_restore_completion(&mut task), + ) + .await + { + Ok(result) => { + clear_owner_restore_task_if(&self.owner_restore_task, task.id); + result + } + Err(_) => { + // The restoration has its own 30-second deadline, so this is + // a stuck task/scheduler boundary. Abort the actual worker and + // wait for its supervisor to join it. A pathological delayed + // join remains in the slot for a later teardown retry. + task.abort.abort(); + if tokio::time::timeout( + Duration::from_secs(1), + wait_for_owner_restore_completion(&mut task), + ) + .await + .is_ok() + { + clear_owner_restore_task_if(&self.owner_restore_task, task.id); + } + Err("owner-playback-restore-timed-out") + } + } + } + + pub fn owner_restore_pending(&self) -> bool { + self.owner_snapshot_pending() + || recover_lock(&self.owner_restore_task) + .as_ref() + .is_some_and(OwnerRestoreTask::is_pending) + } + + pub fn owner_snapshot_pending(&self) -> bool { + recover_lock(&self.owner_snapshot).is_some() + } + + fn volume_mode(&self) -> VolumeMode { + VolumeMode::from_kv(super::transport::load_volume_mode_at(&self.settings_db).as_deref()) + } + + async fn owner_client(&self) -> Result { + self.runtime + .core() + .client() + .read() + .await + .clone() + .ok_or(DelegationErrorCode::ApiRejected) + } + + fn make_app( + &self, + engine: DaemonRendererEngine, + stamp: AuthorityStamp, + ) -> ( + Arc, + Arc, + Arc>, + ) { + let transport = Arc::new(NativeWsTransport::new()); + let sync_state = Arc::new(AsyncMutex::new(QconnectRemoteSyncState::default())); + let sink = Arc::new(DaemonEventSink::new( + engine, + Arc::clone(&sync_state), + Arc::clone(&self.authority), + stamp, + self.projection.clone(), + )); + let app = Arc::new(QconnectApp::new( + transport, + Arc::clone(&sink), + Arc::clone(&sync_state), + )); + sink.set_app(&app); + (app, sink, sync_state) + } + + async fn disconnect_common(common: PreparedCommon) { + let _ = common.app.disconnect().await; + } + + async fn stop_runtime(&self, runtime: DaemonQconnectRuntime) { + runtime.event_loop.abort(); + { + let mut sync = runtime.sync_state.lock().await; + sync.watchdog_generation = sync.watchdog_generation.wrapping_add(1); + sync.session = QconnectSessionState::default(); + sync.session_renderer_states.clear(); + } + let _ = runtime.app.disconnect().await; + let _ = runtime.event_loop.await; + } + + async fn restore_owner_snapshot(&self) -> Option { + // Clone first and clear only after the atomic replacement completes. + // If shutdown cancellation drops this future, the original snapshot + // remains available to the idempotent teardown retry. + let snapshot = recover_lock(&self.owner_snapshot).as_ref().cloned(); + let Some(snapshot) = snapshot else { + return None; + }; + let _ = self.runtime.core().stop(); + self.runtime + .core() + .restore_authority_snapshot(snapshot.queue) + .await; + let _ = self.runtime.core().set_volume(snapshot.volume); + let state = self.runtime.core().get_queue_state().await; + let bus = self + .shared + .lock() + .ok() + .and_then(|shared| shared.bus.clone()); + if let Some(bus) = bus { + let _ = bus.send(CoreEvent::QueueUpdated { state }); + } + recover_lock(&self.owner_snapshot).take(); + Some(snapshot.playback) + } + + fn publish_origin(&self, origin: AuthorityOrigin) { + if let Ok(mut shared) = self.shared.lock() { + shared.qconnect.credential_origin = match origin { + AuthorityOrigin::Owner => "owner", + AuthorityOrigin::Delegated { .. } => "delegated", + } + .to_string(); + shared.emit_qconnect_session_changed(); + } + } +} + +#[async_trait] +impl DelegationHost for DaemonDelegationHost { + type Candidate = HandoffCandidate; + type PreparedDelegation = PreparedDelegation; + type PreparedOwner = PreparedOwner; + type RetiredAuthority = RetiredAuthority; + + async fn prepare_delegation( + &self, + generation: u64, + candidate: HandoffCandidate, + cancellation: DelegationCancellation, + ) -> Result { + let expected_current = self + .authority + .current() + .ok_or(DelegationErrorCode::CommitRejected)?; + let app_credentials = self + .owner_client() + .await? + .delegated_app_credentials() + .await + .map_err(|_| DelegationErrorCode::ApiRejected)?; + + let (session_id, api_token, qws_token, become_active) = candidate.into_parts(); + let session_id = Zeroizing::new(session_id); + let (api_endpoint, api_exp, api_jwt) = api_token.into_parts(); + let api_endpoint = Zeroizing::new(api_endpoint); + let mut api_jwt = Zeroizing::new(api_jwt); + let (qws_endpoint, _qws_exp, qws_jwt) = qws_token.into_parts(); + let mut qws_endpoint = Zeroizing::new(qws_endpoint); + let mut qws_jwt = Zeroizing::new(qws_jwt); + let api_exp = u64::try_from(api_exp).map_err(|_| DelegationErrorCode::CandidateExpired)?; + let api_host = reqwest::Url::parse(&api_endpoint) + .ok() + .and_then(|url| url.host_str().map(str::to_string)) + .ok_or(DelegationErrorCode::ApiRejected)?; + let endpoint = DelegatedApiEndpoint::new(&api_endpoint, &[api_host.as_str()]) + .map_err(|_| DelegationErrorCode::ApiRejected)?; + let api_config = DelegatedApiConfig::new( + endpoint, + api_exp, + std::mem::take(&mut *api_jwt), + app_credentials, + ) + .map_err(|_| DelegationErrorCode::ApiRejected)?; + let delegated_client = Arc::new( + DelegatedQobuzClient::new(api_config).map_err(|_| DelegationErrorCode::ApiRejected)?, + ); + + let stamp = self + .authority + .reserve(AuthorityOrigin::Delegated { generation }); + let volume_mode = self.volume_mode(); + let engine = DaemonRendererEngine::delegated( + Arc::clone(&self.runtime), + Arc::clone(&delegated_client), + volume_mode, + Arc::clone(&self.quality_cap), + Arc::clone(&self.authority), + stamp, + ); + let (app, sink, sync_state) = self.make_app(engine, stamp); + let receiver = app.subscribe_transport_events(); + let mut config = WsTransportConfig::default(); + config.endpoint_url = std::mem::take(&mut *qws_endpoint); + config.jwt_qws = Some(std::mem::take(&mut *qws_jwt)); + config.require_jwt = true; + config.subscribe_channels = vec![vec![0x01], vec![0x02], vec![0x03]]; + config.reconnect_idle_retry_ms = 60_000; + app.connect(config.clone()) + .await + .map_err(|_| DelegationErrorCode::QwsRejected)?; + + let custom_name = self.custom_device_name.read().await.clone(); + let mut common = PreparedCommon { + app, + sink, + config, + sync_state, + receiver, + buffered: VecDeque::new(), + buffered_bytes: 0, + stamp, + expected_current, + volume_mode, + custom_name, + confirmed_owner_session_id: None, + }; + let api_validation = async { + delegated_client + .validate_access() + .await + .map_err(|_| DelegationErrorCode::ApiRejected) + }; + tokio::try_join!( + api_validation, + wait_for_qws_ready(&mut common, cancellation) + )?; + + Ok(PreparedDelegation { + common, + session_id, + become_active, + owner_snapshot: None, + transition_guard: None, + transition_fence: None, + }) + } + + async fn activate_delegation( + &self, + _generation: u64, + prepared: &mut PreparedDelegation, + cancellation: DelegationCancellation, + ) -> Result<(), DelegationErrorCode> { + if !self.authority.is_current(prepared.common.expected_current) { + return Err(DelegationErrorCode::CandidateCancelled); + } + // Serialize the fallible activation/commit/retirement tail before + // closing ordinary action admission. The guard remains in `prepared` + // through commit and moves into `PendingActivation`; owner rollback + // keeps it until playback is restored. A second fence is not a mutex, + // so this explicit gate is what prevents overlapping transitions. + prepared.transition_guard = Some(Arc::clone(&self.transition_gate).lock_owned().await); + + // Fence every installed authority, including delegated -> delegated: + // runtime action permits can span catalog/stream awaits and must drain + // before cloud activation. Join is deliberately the final I/O before + // the stamped swap; once SET_ACTIVE=true arrives, commit stays short. + // Only the owner additionally needs a queue snapshot for restore. + prepared.transition_fence = Some(OwnerActionFence::acquire(Arc::clone(&self.authority))); + self.authority.wait_for_actions_drained().await; + if !self.authority.is_current(prepared.common.expected_current) { + return Err(DelegationErrorCode::CandidateCancelled); + } + if prepared.common.expected_current.origin() == AuthorityOrigin::Owner { + let queue = self.runtime.core().capture_authority_snapshot().await; + if !self.authority.is_current(prepared.common.expected_current) { + return Err(DelegationErrorCode::CandidateCancelled); + } + let playback_state = self.runtime.core().get_playback_state(); + let queue_track_id = self + .runtime + .core() + .current_track() + .await + .map(|track| track.id); + if !self.authority.is_current(prepared.common.expected_current) { + return Err(DelegationErrorCode::CandidateCancelled); + } + prepared.owner_snapshot = Some(OwnerSnapshot { + queue, + volume: playback_state.volume, + playback: OwnerPlaybackSnapshot { + track_id: playback_state.track_id, + position_secs: playback_state.position, + was_playing: playback_state.is_playing, + had_loaded_audio: self.runtime.core().player().has_loaded_audio() + && playback_state.track_id != 0 + && queue_track_id == Some(playback_state.track_id), + }, + }); + } + + send_delegated_join( + &prepared.common.app, + prepared.session_id.as_str(), + prepared.become_active, + prepared.common.custom_name.as_deref(), + current_quality(&self.quality_cap), + ) + .await?; + wait_for_activation(&mut prepared.common, cancellation).await?; + if !self.authority.is_current(prepared.common.expected_current) { + return Err(DelegationErrorCode::CandidateCancelled); + } + Ok(()) + } + + fn commit_delegation( + &self, + generation: u64, + prepared: PreparedDelegation, + ) -> Result> { + let mut inner = lock_inner(&self.inner); + if prepared.common.stamp.origin() != (AuthorityOrigin::Delegated { generation }) + || !self.authority.is_current(prepared.common.expected_current) + || inner.runtime.as_ref().map(|runtime| runtime.stamp) + != Some(prepared.common.expected_current) + || prepared.transition_guard.is_none() + || prepared.transition_fence.is_none() + { + return Err(CommitRejected::new( + prepared, + DelegationErrorCode::CommitRejected, + )); + } + if prepared.common.expected_current.origin() == AuthorityOrigin::Owner + && prepared.owner_snapshot.is_none() + { + return Err(CommitRejected::new( + prepared, + DelegationErrorCode::CommitRejected, + )); + } + if !self.projection.install_authority( + &self.authority, + prepared.common.stamp, + Some(prepared.session_id.as_str()), + ) { + return Err(CommitRejected::new( + prepared, + DelegationErrorCode::CommitRejected, + )); + } + + let retired = inner.runtime.take().expect("runtime stamp was checked"); + retired.event_loop.abort(); + let PreparedDelegation { + common, + session_id, + become_active: _, + owner_snapshot, + transition_guard, + transition_fence, + } = prepared; + let (runtime, start) = spawn_delegated_runtime( + common, + session_id, + generation, + Arc::clone(&self.inner), + Arc::clone(&self.shared), + Arc::clone(&self.authority), + self.coordinator(), + current_quality(&self.quality_cap), + ); + inner.last_error = None; + inner.last_pushed_queue_ids = None; + let installed_stamp = runtime.stamp; + inner.runtime = Some(runtime); + drop(inner); + + if let Some(snapshot) = owner_snapshot { + *recover_lock(&self.owner_snapshot) = Some(snapshot); + } + self.publish_origin(AuthorityOrigin::Delegated { generation }); + Ok(RetiredAuthority { + runtime: Some(retired), + activation: Some(PendingActivation { + start: Some(start), + runtime: Arc::clone(&self.runtime), + authority: Arc::clone(&self.authority), + stamp: installed_stamp, + kind: PendingActivationKind::Delegated, + transition_guard, + transition_fence, + owner_restore_task: Arc::clone(&self.owner_restore_task), + owner_quality: current_quality(&self.quality_cap), + }), + }) + } + + async fn discard_delegation(&self, _generation: u64, prepared: PreparedDelegation) { + Self::disconnect_common(prepared.common).await; + } + + async fn prepare_owner( + &self, + _generation: u64, + _reason: RestoreReason, + cancellation: DelegationCancellation, + ) -> Result { + let expected_current = self + .authority + .current() + .filter(|stamp| matches!(stamp.origin(), AuthorityOrigin::Delegated { .. })) + .ok_or(DelegationErrorCode::CommitRejected)?; + let config = resolve_transport_config(&self.runtime) + .await + .map_err(|_| DelegationErrorCode::OwnerRestoreFailed)?; + let stamp = self.authority.reserve(AuthorityOrigin::Owner); + let volume_mode = self.volume_mode(); + let engine = DaemonRendererEngine::owner( + Arc::clone(&self.runtime), + volume_mode, + Arc::clone(&self.quality_cap), + Arc::clone(&self.authority), + stamp, + ); + let (app, sink, sync_state) = self.make_app(engine, stamp); + let receiver = app.subscribe_transport_events(); + app.connect(config.clone()) + .await + .map_err(|_| DelegationErrorCode::OwnerRestoreFailed)?; + let custom_name = self.custom_device_name.read().await.clone(); + let mut common = PreparedCommon { + app, + sink, + config, + sync_state, + receiver, + buffered: VecDeque::new(), + buffered_bytes: 0, + stamp, + expected_current, + volume_mode, + custom_name, + confirmed_owner_session_id: None, + }; + wait_for_qws_ready(&mut common, cancellation.clone()) + .await + .map_err(|_| DelegationErrorCode::OwnerRestoreFailed)?; + bootstrap_prepared_owner_presence(&common.app, common.custom_name.clone()) + .await + .map_err(|_| DelegationErrorCode::OwnerRestoreFailed)?; + wait_for_owner_session(&mut common, cancellation) + .await + .map_err(|_| DelegationErrorCode::OwnerRestoreFailed)?; + let transition_guard = Arc::clone(&self.transition_gate).lock_owned().await; + let transition_fence = OwnerActionFence::acquire(Arc::clone(&self.authority)); + self.authority.wait_for_actions_drained().await; + if !self.authority.is_current(expected_current) { + return Err(DelegationErrorCode::CandidateCancelled); + } + Ok(PreparedOwner { + common, + transition_guard, + transition_fence, + }) + } + + fn commit_owner( + &self, + _generation: u64, + prepared: PreparedOwner, + ) -> Result> { + let mut inner = lock_inner(&self.inner); + if !self.authority.is_current(prepared.common.expected_current) + || inner.runtime.as_ref().map(|runtime| runtime.stamp) + != Some(prepared.common.expected_current) + { + return Err(CommitRejected::new( + prepared, + DelegationErrorCode::CommitRejected, + )); + } + let stamp = prepared.common.stamp; + let Some(owner_session_id) = prepared.common.confirmed_owner_session_id.clone() else { + return Err(CommitRejected::new( + prepared, + DelegationErrorCode::CommitRejected, + )); + }; + let Some(owner_snapshot) = recover_lock(&self.owner_snapshot).take() else { + return Err(CommitRejected::new( + prepared, + DelegationErrorCode::CommitRejected, + )); + }; + let OwnerSnapshot { + queue, + volume, + playback, + } = owner_snapshot; + let queue_state = match self + .runtime + .core() + .try_commit_authority_snapshot(queue, || { + self.projection.install_authority( + &self.authority, + stamp, + Some(owner_session_id.as_str()), + ) + }) { + Ok(state) => state, + Err(queue) => { + *recover_lock(&self.owner_snapshot) = Some(OwnerSnapshot { + queue, + volume, + playback, + }); + return Err(CommitRejected::new( + prepared, + DelegationErrorCode::CommitRejected, + )); + } + }; + let retired = inner.runtime.take().expect("runtime stamp was checked"); + retired.event_loop.abort(); + let PreparedOwner { + common, + transition_guard, + transition_fence, + } = prepared; + let (runtime, start) = spawn_owner_runtime( + common, + Arc::clone(&self.inner), + Arc::clone(&self.runtime), + Arc::clone(&self.shared), + Arc::clone(&self.authority), + self.projection.clone(), + ); + inner.last_error = None; + inner.last_pushed_queue_ids = None; + inner.runtime = Some(runtime); + drop(inner); + + let bus = self + .shared + .lock() + .ok() + .and_then(|shared| shared.bus.clone()); + if let Some(bus) = bus { + let _ = bus.send(CoreEvent::QueueUpdated { state: queue_state }); + } + + self.publish_origin(AuthorityOrigin::Owner); + Ok(RetiredAuthority { + runtime: Some(retired), + activation: Some(PendingActivation { + start: Some(start), + runtime: Arc::clone(&self.runtime), + authority: Arc::clone(&self.authority), + stamp, + kind: PendingActivationKind::Owner { volume, playback }, + transition_guard: Some(transition_guard), + transition_fence: Some(transition_fence), + owner_restore_task: Arc::clone(&self.owner_restore_task), + owner_quality: current_quality(&self.quality_cap), + }), + }) + } + + async fn discard_owner(&self, _generation: u64, prepared: PreparedOwner) { + Self::disconnect_common(prepared.common).await; + } + + async fn retire_authority(&self, mut retired: RetiredAuthority) { + if let Some(runtime) = retired.runtime.take() { + self.stop_runtime(runtime).await; + } + + if let Some(mut activation) = retired.activation.take() { + activation.release(); + } + } + + async fn shutdown_authority(&self) { + // A restore from the preceding authority transition owns this gate + // until it has either stabilized playback or failed. Waiting here is + // intentional: cancelling that valid restore would lose the original + // owner stream just because a later candidate/shutdown arrived. + let transition_guard = Arc::clone(&self.transition_gate).lock_owned().await; + let shutdown_fence = OwnerActionFence::acquire(Arc::clone(&self.authority)); + self.authority.wait_for_actions_drained().await; + let previous = self.projection.clear_authority(&self.authority); + let was_delegated = previous + .is_some_and(|stamp| matches!(stamp.origin(), AuthorityOrigin::Delegated { .. })); + let runtime = { + let mut inner = lock_inner(&self.inner); + inner.lifecycle_state = QconnectLifecycleState::Off; + inner.last_pushed_queue_ids = None; + inner.runtime.take() + }; + if let Some(runtime) = runtime { + runtime.event_loop.abort(); + self.stop_runtime(runtime).await; + } + let has_owner_snapshot = recover_lock(&self.owner_snapshot).is_some(); + let playback_restore = if (was_delegated || has_owner_snapshot) + && self.shutdown_mode.load(Ordering::Acquire) == SHUTDOWN_RESTORE_OWNER + { + self.restore_owner_snapshot().await + } else if was_delegated || has_owner_snapshot { + let _ = self.runtime.core().stop(); + recover_lock(&self.owner_snapshot).take(); + None + } else { + // A pending candidate may have captured a snapshot, but disabling an + // installed owner must not stop or replace local playback. + recover_lock(&self.owner_snapshot).take(); + None + }; + self.publish_origin(AuthorityOrigin::Owner); + discard_completed_owner_restore_task(&self.owner_restore_task); + let mut release = + DeferredActivationRelease::new(None, Some(shutdown_fence), Some(transition_guard)); + if let Some(playback) = playback_restore { + schedule_offline_owner_playback_restore( + Arc::clone(&self.runtime), + Arc::clone(&self.authority), + playback, + current_quality(&self.quality_cap), + Arc::clone(&self.owner_restore_task), + release, + ); + } else { + release.release(); + } + } +} + +fn recover_lock(mutex: &StdMutex) -> std::sync::MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +fn next_owner_restore_task_id() -> u64 { + NEXT_OWNER_RESTORE_TASK_ID + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| { + current.checked_add(1) + }) + .expect("owner playback restore task id space exhausted") +} + +async fn wait_for_owner_restore_completion(task: &mut OwnerRestoreTask) -> OwnerRestoreResult { + loop { + if let Some(result) = *task.completion.borrow_and_update() { + return result; + } + if task.completion.changed().await.is_err() { + return (*task.completion.borrow()) + .unwrap_or(Err("owner-playback-restore-task-failed")); + } + } +} + +fn clear_owner_restore_task_if(slot: &StdMutex>, expected_id: u64) { + let mut task = recover_lock(slot); + if task.as_ref().map(|task| task.id) == Some(expected_id) { + task.take(); + } +} + +fn discard_completed_owner_restore_task(slot: &StdMutex>) { + let mut task = recover_lock(slot); + if task + .as_ref() + .is_some_and(|task| task.mutation_finished.load(Ordering::Acquire)) + { + task.take(); + } +} + +/// Installs a restoration before its start barrier is opened. A live task can +/// only own this slot while it also owns `transition_gate`, so seeing one here +/// is an invariant failure. Preserve that valid older task and cancel only the +/// impossible overlapping newcomer. +fn install_owner_restore_task( + slot: &StdMutex>, + task: OwnerRestoreTask, +) -> bool { + let mut current = recover_lock(slot); + if current + .as_ref() + .is_some_and(|current| !current.mutation_finished.load(Ordering::Acquire)) + { + log::error!("[QConnect] overlapping owner playback restoration rejected"); + task.abort.abort(); + return false; + } + *current = Some(task); + true +} + +/// Spawn the mutating worker behind a start barrier and a non-cancellable +/// supervisor. The supervisor owns the transition release, joins the worker, +/// then publishes completion. Callers can therefore abandon their wait without +/// detaching either the mutation or its authority gates. +fn spawn_tracked_owner_restore( + restore: F, + mut release: DeferredActivationRelease, +) -> (OwnerRestoreTask, oneshot::Sender<()>) +where + F: Future + Send + 'static, +{ + let id = next_owner_restore_task_id(); + let (start, started) = oneshot::channel(); + let worker = tokio::spawn(async move { + if started.await.is_err() { + return Err("owner-playback-restore-cancelled"); + } + restore.await + }); + let abort = worker.abort_handle(); + let mutation_finished = Arc::new(AtomicBool::new(false)); + let supervisor_finished = Arc::clone(&mutation_finished); + let (completion_tx, completion) = watch::channel(None); + tokio::spawn(async move { + let result = match worker.await { + Ok(result) => result, + Err(error) if error.is_cancelled() => Err("owner-playback-restore-cancelled"), + Err(_) => Err("owner-playback-restore-task-failed"), + }; + + // Mark the mutation finished before opening the transition mutex. A + // newly admitted transition may run on another executor thread before + // the watch publication below, but it can now replace this task slot + // without aborting or losing a live restoration. + supervisor_finished.store(true, Ordering::Release); + release.release(); + completion_tx.send_replace(Some(result)); + }); + ( + OwnerRestoreTask { + id, + abort, + mutation_finished, + completion, + }, + start, + ) +} + +fn schedule_owner_playback_restore( + runtime: Runtime, + authority: Arc, + stamp: AuthorityStamp, + playback: OwnerPlaybackSnapshot, + quality: Quality, + slot: Arc>>, + mut release: DeferredActivationRelease, +) { + discard_completed_owner_restore_task(&slot); + if !playback.had_loaded_audio { + release.release(); + return; + } + let restore = async move { + if !authority.is_current(stamp) { + return Err("owner-playback-restore-authority-changed"); + } + let restore = async { + let current = runtime.core().current_track().await; + if current.as_ref().map(|track| track.id) != Some(playback.track_id) + || !authority.is_current(stamp) + { + return Err("restored owner queue does not match playback snapshot".to_string()); + } + runtime + .core() + .play_track_resolved( + playback.track_id, + quality, + None, + None, + playback.position_secs, + ) + .await?; + if !authority.is_current(stamp) { + return Err("owner playback authority changed".to_string()); + } + if !playback.was_playing { + runtime.core().pause().map_err(|error| error.to_string())?; + } + Ok::<(), String>(()) + }; + match tokio::time::timeout(Duration::from_secs(30), restore).await { + Ok(Ok(())) => Ok(()), + Ok(Err(_)) => { + if authority.is_current(stamp) { + let _ = runtime.core().stop(); + } + log::warn!("[QConnect] owner playback restoration failed"); + Err("owner-playback-restore-failed") + } + Err(_) => { + if authority.is_current(stamp) { + let _ = runtime.core().stop(); + } + log::warn!("[QConnect] owner playback restoration timed out"); + Err("owner-playback-restore-timed-out") + } + } + }; + let (task, start) = spawn_tracked_owner_restore(restore, release); + if install_owner_restore_task(&slot, task) { + let _ = start.send(()); + } +} + +fn schedule_offline_owner_playback_restore( + runtime: Runtime, + authority: Arc, + playback: OwnerPlaybackSnapshot, + quality: Quality, + slot: Arc>>, + mut release: DeferredActivationRelease, +) { + discard_completed_owner_restore_task(&slot); + if !playback.had_loaded_audio { + release.release(); + return; + } + let restore = async move { + if authority.current().is_some() { + return Err("owner-playback-restore-authority-changed"); + } + let restore = async { + let current = runtime.core().current_track().await; + if current.as_ref().map(|track| track.id) != Some(playback.track_id) + || authority.current().is_some() + { + return Err("restored owner queue does not match playback snapshot".to_string()); + } + runtime + .core() + .play_track_resolved( + playback.track_id, + quality, + None, + None, + playback.position_secs, + ) + .await?; + if authority.current().is_some() { + return Err("owner-playback-restore-authority-changed".to_string()); + } + if !playback.was_playing { + runtime.core().pause().map_err(|error| error.to_string())?; + } + Ok::<(), String>(()) + }; + match tokio::time::timeout(Duration::from_secs(30), restore).await { + Ok(Ok(())) => Ok(()), + Ok(Err(_)) => { + if authority.current().is_none() { + let _ = runtime.core().stop(); + } + log::warn!("[QConnect] offline owner playback restoration failed"); + Err("owner-playback-restore-failed") + } + Err(_) => { + if authority.current().is_none() { + let _ = runtime.core().stop(); + } + log::warn!("[QConnect] offline owner playback restoration timed out"); + Err("owner-playback-restore-timed-out") + } + } + }; + let (task, start) = spawn_tracked_owner_restore(restore, release); + if install_owner_restore_task(&slot, task) { + let _ = start.send(()); + } +} + +fn current_quality(quality: &StdMutex) -> Quality { + *recover_lock(quality) +} + +fn quality_wire(quality: Quality) -> i32 { + match quality { + Quality::Mp3 => 1, + Quality::Lossless => 2, + Quality::HiRes => 3, + Quality::UltraHiRes => 4, + } +} + +fn retained_event_bytes(event: &TransportEvent) -> usize { + match event { + TransportEvent::InboundPayloadBytes { payload, .. } => payload.len(), + _ => 0, + } +} + +fn should_retain(event: &TransportEvent) -> bool { + matches!( + event, + TransportEvent::Connected + | TransportEvent::Disconnected + | TransportEvent::SessionEstablished + | TransportEvent::InboundPayloadBytes { .. } + | TransportEvent::InboundQueueServerEvent(_) + | TransportEvent::InboundRendererServerCommand(_) + | TransportEvent::InboundReceived(_) + ) +} + +fn retain_event( + common: &mut PreparedCommon, + event: TransportEvent, +) -> Result<(), DelegationErrorCode> { + if !should_retain(&event) { + return Ok(()); + } + let bytes = retained_event_bytes(&event); + if common.buffered.len() >= PREFLIGHT_BUFFER_EVENTS + || common.buffered_bytes.saturating_add(bytes) > PREFLIGHT_BUFFER_BYTES + { + return Err(DelegationErrorCode::Internal); + } + common.buffered_bytes = common.buffered_bytes.saturating_add(bytes); + common.buffered.push_back(event); + Ok(()) +} + +async fn next_preflight_event( + common: &mut PreparedCommon, + mut cancellation: DelegationCancellation, +) -> Result { + tokio::select! { + _ = cancellation.cancelled() => Err(DelegationErrorCode::CandidateCancelled), + event = common.receiver.recv() => match event { + Ok(event) => Ok(event), + Err(broadcast::error::RecvError::Lagged(_)) => Err(DelegationErrorCode::Internal), + Err(broadcast::error::RecvError::Closed) => Err(DelegationErrorCode::QwsRejected), + } + } +} + +async fn wait_for_qws_ready( + common: &mut PreparedCommon, + cancellation: DelegationCancellation, +) -> Result<(), DelegationErrorCode> { + let mut authenticated = false; + let mut subscribed = false; + while !(authenticated && subscribed) { + let event = next_preflight_event(common, cancellation.clone()).await?; + match &event { + TransportEvent::Authenticated => authenticated = true, + TransportEvent::Subscribed => subscribed = true, + TransportEvent::Disconnected + | TransportEvent::CloudError { .. } + | TransportEvent::MaxReconnectAttemptsExceeded { .. } => { + return Err(DelegationErrorCode::QwsRejected) + } + _ => {} + } + retain_event(common, event)?; + } + Ok(()) +} + +async fn wait_for_activation( + common: &mut PreparedCommon, + cancellation: DelegationCancellation, +) -> Result<(), DelegationErrorCode> { + loop { + let event = next_preflight_event(common, cancellation.clone()).await?; + let accepted = matches!( + &event, + TransportEvent::InboundRendererServerCommand(command) + if command.command_type == RendererCommandType::SrvrRndrSetActive + && command.payload.get("active").and_then(serde_json::Value::as_bool) + == Some(true) + ); + if matches!( + &event, + TransportEvent::Disconnected + | TransportEvent::CloudError { .. } + | TransportEvent::MaxReconnectAttemptsExceeded { .. } + ) { + return Err(DelegationErrorCode::ActivationRejected); + } + retain_event(common, event)?; + if accepted { + return Ok(()); + } + } +} + +/// Owner preparation is complete only after the cloud has accepted the +/// controller-side join and emitted the same establishment signal consumed by +/// a live session loop. Authentication/subscription and a successful socket +/// send are necessary but not sufficient to publish `OwnerReady`. +async fn wait_for_owner_session( + common: &mut PreparedCommon, + cancellation: DelegationCancellation, +) -> Result<(), DelegationErrorCode> { + let mut established = false; + loop { + let event = next_preflight_event(common, cancellation.clone()).await?; + if let Some(session_id) = owner_session_id_from_event(&event).map(str::to_string) { + common.confirmed_owner_session_id = Some(Zeroizing::new(session_id)); + } + established |= matches!(&event, TransportEvent::SessionEstablished); + if matches!( + &event, + TransportEvent::Disconnected + | TransportEvent::CloudError { .. } + | TransportEvent::MaxReconnectAttemptsExceeded { .. } + ) { + return Err(DelegationErrorCode::OwnerRestoreFailed); + } + retain_event(common, event)?; + if established && common.confirmed_owner_session_id.is_some() { + return Ok(()); + } + } +} + +fn owner_session_id_from_event(event: &TransportEvent) -> Option<&str> { + match event { + TransportEvent::InboundQueueServerEvent(event) + if event.message_type() == "MESSAGE_TYPE_SRVR_CTRL_SESSION_STATE" => + { + event + .payload + .get("session_uuid") + .and_then(serde_json::Value::as_str) + .filter(|session_id| !session_id.is_empty()) + } + _ => None, + } +} + +async fn send_delegated_join( + app: &Arc, + session_id: &str, + become_active: bool, + custom_name: Option<&str>, + quality: Quality, +) -> Result<(), DelegationErrorCode> { + let mut device_info = default_qconnect_device_info_with_name(custom_name); + if let Some(capabilities) = device_info.capabilities.as_mut() { + capabilities.max_audio_quality = Some(quality_wire(quality)); + } + let queue_version = app.queue_state_snapshot().await.version; + let report = RendererReport::new( + RendererReportType::RndrSrvrJoinSession, + Uuid::new_v4().to_string(), + queue_version, + serde_json::json!({ + "session_uuid": session_id, + "device_info": serde_json::to_value(device_info).unwrap_or_default(), + "is_active": become_active, + "reason": JOIN_SESSION_REASON_CONTROLLER_REQUEST, + "initial_state": { + "playing_state": PLAYING_STATE_STOPPED, + "buffer_state": RendererBufferState::Ok.as_i32(), + "current_position": 0, + "duration": 0, + "queue_version": { + "major": queue_version.major, + "minor": queue_version.minor, + } + } + }), + ); + app.send_renderer_report_command(report) + .await + .map_err(|_| DelegationErrorCode::ActivationRejected) +} + +#[allow(clippy::too_many_arguments)] +fn spawn_delegated_runtime( + common: PreparedCommon, + session_id: Zeroizing, + generation: u64, + inner: Arc>, + shared: SharedState, + authority: Arc, + coordinator: Option, + quality: Quality, +) -> (DaemonQconnectRuntime, oneshot::Sender<()>) { + let PreparedCommon { + app, + sink, + config, + sync_state, + receiver, + buffered, + stamp, + custom_name, + .. + } = common; + let (start, started) = oneshot::channel(); + let loop_app = Arc::clone(&app); + let loop_sink = Arc::clone(&sink); + let loop_sync = Arc::clone(&sync_state); + let loop_authority = Arc::clone(&authority); + let event_loop = tokio::spawn(async move { + if started.await.is_err() || !loop_authority.is_current(stamp) { + return; + } + loop_sink + .on_event(QconnectAppEvent::TransportConnected) + .await; + run_delegated_loop( + loop_app, + loop_sink, + loop_sync, + receiver, + buffered, + session_id, + generation, + inner, + shared, + loop_authority, + stamp, + coordinator, + custom_name, + quality, + ) + .await; + }); + ( + DaemonQconnectRuntime { + stamp, + app, + config, + event_loop, + sync_state, + }, + start, + ) +} + +fn spawn_owner_runtime( + common: PreparedCommon, + inner: Arc>, + runtime: Runtime, + shared: SharedState, + authority: Arc, + projection: DaemonLanProjectionSlot, +) -> (DaemonQconnectRuntime, oneshot::Sender<()>) { + let PreparedCommon { + app, + sink, + config, + sync_state, + receiver, + buffered, + stamp, + volume_mode, + .. + } = common; + let idle_retry_active = config.reconnect_idle_retry_ms > 0; + let host: Arc = Arc::new(DaemonSessionLoopHost { + app: Arc::clone(&app), + sync_state: Arc::clone(&sync_state), + inner, + authority: Arc::clone(&authority), + stamp, + sink, + runtime, + shared, + volume_mode, + projection, + }); + let (start, started) = oneshot::channel(); + let loop_app = Arc::clone(&app); + let loop_authority = Arc::clone(&authority); + let event_loop = tokio::spawn(async move { + if started.await.is_err() || !loop_authority.is_current(stamp) { + return; + } + loop_app + .run_session_loop_with_prefetched(host, receiver, idle_retry_active, buffered) + .await; + }); + ( + DaemonQconnectRuntime { + stamp, + app, + config, + event_loop, + sync_state, + }, + start, + ) +} + +#[allow(clippy::too_many_arguments)] +async fn run_delegated_loop( + app: Arc, + sink: Arc, + _sync_state: Arc>, + mut receiver: broadcast::Receiver, + buffered: VecDeque, + session_id: Zeroizing, + generation: u64, + inner: Arc>, + shared: SharedState, + authority: Arc, + stamp: AuthorityStamp, + coordinator: Option, + custom_name: Option, + quality: Quality, +) { + let mut disconnected = false; + let mut rejoin_watchdog = DelegatedRejoinWatchdog::new(); + for event in buffered { + if !handle_delegated_event( + &app, + &sink, + event, + &session_id, + generation, + &inner, + &shared, + &authority, + stamp, + coordinator.as_ref(), + custom_name.as_deref(), + quality, + &mut disconnected, + &mut rejoin_watchdog, + ) + .await + { + return; + } + } + loop { + let event = match receiver.recv().await { + Ok(event) => event, + Err(broadcast::error::RecvError::Lagged(_)) => { + request_restore(coordinator.as_ref(), generation).await; + return; + } + Err(broadcast::error::RecvError::Closed) => { + request_restore(coordinator.as_ref(), generation).await; + return; + } + }; + if !handle_delegated_event( + &app, + &sink, + event, + &session_id, + generation, + &inner, + &shared, + &authority, + stamp, + coordinator.as_ref(), + custom_name.as_deref(), + quality, + &mut disconnected, + &mut rejoin_watchdog, + ) + .await + { + return; + } + } +} + +#[allow(clippy::too_many_arguments)] +async fn handle_delegated_event( + app: &Arc, + sink: &Arc, + event: TransportEvent, + session_id: &str, + generation: u64, + inner: &Arc>, + shared: &SharedState, + authority: &Arc, + stamp: AuthorityStamp, + coordinator: Option<&DaemonDelegationCoordinator>, + custom_name: Option<&str>, + quality: Quality, + disconnected: &mut bool, + rejoin_watchdog: &mut DelegatedRejoinWatchdog, +) -> bool { + if !authority.is_current(stamp) { + return false; + } + match &event { + TransportEvent::Disconnected => { + *disconnected = true; + rejoin_watchdog.cancel(); + update_lifecycle_state_if_running( + inner, + sink, + shared, + authority, + stamp, + QconnectLifecycleState::Reconnecting, + ) + .await; + } + TransportEvent::Subscribed if *disconnected => { + if send_delegated_join(app, session_id, true, custom_name, quality) + .await + .is_err() + { + request_restore(coordinator, generation).await; + return false; + } + rejoin_watchdog.arm( + Arc::clone(authority), + stamp, + generation, + coordinator.cloned(), + ); + } + TransportEvent::SessionEstablished => { + *disconnected = false; + rejoin_watchdog.cancel(); + update_lifecycle_state_if_running( + inner, + sink, + shared, + authority, + stamp, + QconnectLifecycleState::Connected, + ) + .await; + } + TransportEvent::CloudError { .. } | TransportEvent::MaxReconnectAttemptsExceeded { .. } => { + request_restore(coordinator, generation).await; + return false; + } + _ => {} + } + if !authority.is_current(stamp) { + return false; + } + if let Err(error) = app.handle_transport_event(event).await { + if authority.is_current(stamp) { + log::warn!("[QConnect] delegated event application failed: {error}"); + request_restore(coordinator, generation).await; + } + return false; + } + authority.is_current(stamp) +} + +async fn request_restore(coordinator: Option<&DaemonDelegationCoordinator>, generation: u64) { + if let Some(coordinator) = coordinator { + let _ = coordinator + .restore_owner_if_active(generation, RestoreReason::TransportFatal) + .await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn quality_mapping_never_exceeds_the_configured_cap() { + assert_eq!(quality_wire(Quality::Mp3), 1); + assert_eq!(quality_wire(Quality::Lossless), 2); + assert_eq!(quality_wire(Quality::HiRes), 3); + assert_eq!(quality_wire(Quality::UltraHiRes), 4); + } + + #[test] + fn preflight_retains_only_events_needed_after_commit() { + assert!(!should_retain(&TransportEvent::Authenticated)); + assert!(!should_retain(&TransportEvent::KeepalivePingSent)); + assert!(should_retain(&TransportEvent::Disconnected)); + assert!(should_retain(&TransportEvent::SessionEstablished)); + } + + #[test] + fn rejoin_watchdog_generation_is_claimed_once() { + let generation = RejoinWatchdogGeneration::default(); + let armed = generation.advance(); + + assert!(generation.claim(armed)); + assert!(!generation.claim(armed)); + } + + #[test] + fn rejoin_watchdog_ignores_cancelled_and_replaced_generations() { + let generation = RejoinWatchdogGeneration::default(); + let cancelled = generation.advance(); + generation.advance(); + let replacement = generation.advance(); + + assert!(!generation.claim(cancelled)); + assert!(generation.claim(replacement)); + } +} diff --git a/crates/qbzd/src/qconnect/engine.rs b/crates/qbzd/src/qconnect/engine.rs index e39228723..faecb1b23 100644 --- a/crates/qbzd/src/qconnect/engine.rs +++ b/crates/qbzd/src/qconnect/engine.rs @@ -25,13 +25,18 @@ use std::sync::Arc; use async_trait::async_trait; use qbz_app::shell::AppRuntime; -use qbz_core::QbzCore; +use qbz_core::{CoreError, QbzCore}; use qbz_models::{Quality, QueueTrack, RepeatMode, Track}; use qbz_player::PlaybackState; -use qconnect_app::QconnectRendererEngine; +use qbz_qobuz::{ApiError, DelegatedQobuzClient}; +use qconnect_app::{QconnectOwnerFailure, QconnectRendererEngine}; use crate::adapter::DaemonAdapter; +use super::authority::{AuthorityActionPermit, AuthorityCell, AuthorityOrigin, AuthorityStamp}; + +const RETIRED_AUTHORITY_ERROR: &str = "qconnect renderer authority is retired"; + // T10 (OD4, §7.4): daemon-only volume policy. The desktop has no equivalent — // it always applies remote volume. The mode is read from the daemon-root // `qconnect_settings.db` `volume_mode` KV key (transport::load_volume_mode_at) @@ -76,33 +81,280 @@ impl VolumeMode { } } +/// Credential boundary used for all Qobuz catalog and stream-resolution calls +/// made by this renderer engine. +/// +/// A delegated client is a complete, isolated API context. Keeping the origin +/// in this enum makes it impossible for the guest path to silently consult the +/// owner's `QbzCore` API client when delegated metadata or stream resolution +/// fails. +pub enum RendererCatalogAuthority { + Owner, + Delegated(Arc), +} + +impl std::fmt::Debug for RendererCatalogAuthority { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Owner => formatter.write_str("Owner"), + Self::Delegated(_) => formatter.write_str("Delegated()"), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RendererAuthorityOrigin { + Owner, + Delegated, +} + +/// Collapse owner API failures before they cross the QConnect engine seam. +/// Never format `error`: its nested reqwest URL or remote body can be sensitive. +fn classify_owner_api_failure(error: &ApiError) -> QconnectOwnerFailure { + match error { + ApiError::AuthenticationError(_) + | ApiError::InvalidAppId + | ApiError::InvalidAppSecret + | ApiError::BundleExtractionError(_) => QconnectOwnerFailure::Authentication, + ApiError::IneligibleUser | ApiError::Forbidden(_) | ApiError::ForbiddenCircuitOpen(_) => { + QconnectOwnerFailure::Authorization + } + ApiError::NonStreamable + | ApiError::InvalidQuality(_) + | ApiError::NoQualityAvailable + | ApiError::TrackUnavailable(_) => QconnectOwnerFailure::TrackUnavailable, + ApiError::OfflineMode => QconnectOwnerFailure::Offline, + ApiError::NetworkError(_) => QconnectOwnerFailure::Network, + ApiError::ParseError(_) | ApiError::ApiResponse(_) => QconnectOwnerFailure::InvalidResponse, + ApiError::RateLimited(_) => QconnectOwnerFailure::RateLimited, + ApiError::ServerError(_) => QconnectOwnerFailure::Server, + } +} + +/// Typed owner-core classifier paired with [`classify_owner_api_failure`]. +/// String-bearing variants are intentionally reduced to stable categories. +fn classify_owner_core_failure(error: &CoreError) -> QconnectOwnerFailure { + match error { + CoreError::AuthRequired | CoreError::AuthFailed(_) => QconnectOwnerFailure::Authentication, + CoreError::Api(error) => classify_owner_api_failure(error), + CoreError::Player(_) | CoreError::Playback(_) | CoreError::Audio(_) => { + QconnectOwnerFailure::Playback + } + CoreError::Queue(_) | CoreError::Internal(_) => QconnectOwnerFailure::Internal, + CoreError::NotInitialized => QconnectOwnerFailure::Unavailable, + } +} + +impl RendererCatalogAuthority { + pub const fn origin(&self) -> RendererAuthorityOrigin { + match self { + Self::Owner => RendererAuthorityOrigin::Owner, + Self::Delegated(_) => RendererAuthorityOrigin::Delegated, + } + } + + async fn get_track( + &self, + core: &QbzCore, + track_id: u64, + ) -> Result { + match self { + Self::Owner => core + .get_track(track_id) + .await + .map_err(|err| classify_owner_core_failure(&err).to_string()), + Self::Delegated(client) => client + .get_track(track_id) + .await + .map_err(|err| format!("delegated catalog request for track {track_id}: {err}")), + } + } + + async fn get_tracks_batch( + &self, + core: &QbzCore, + track_ids: &[u64], + ) -> Result, String> { + match self { + Self::Owner => core + .get_tracks_batch(track_ids) + .await + .map_err(|err| classify_owner_core_failure(&err).to_string()), + Self::Delegated(client) => client + .get_tracks_batch(track_ids) + .await + .map_err(|err| format!("delegated catalog batch request failed: {err}")), + } + } + + async fn get_stream_url( + &self, + core: &QbzCore, + track_id: u64, + quality: Quality, + ) -> Result { + match self { + Self::Owner => core.get_stream_url(track_id, quality).await.map_err(|err| { + format!( + "resolve owner stream for track {track_id}: {}", + classify_owner_core_failure(&err) + ) + }), + Self::Delegated(client) => client + .get_stream_url_with_fallback(track_id, quality) + .await + .map_err(|err| format!("resolve delegated stream for track {track_id}: {err}")), + } + } +} + +fn authority_origins_match(catalog: RendererAuthorityOrigin, stamp: AuthorityOrigin) -> bool { + matches!( + (catalog, stamp), + (RendererAuthorityOrigin::Owner, AuthorityOrigin::Owner) + | ( + RendererAuthorityOrigin::Delegated, + AuthorityOrigin::Delegated { .. } + ) + ) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StreamRecoveryAction { + FullDownload, + OwnerCmaf, + FailClosed, +} + +fn recovery_action(origin: RendererAuthorityOrigin, header_flood: bool) -> StreamRecoveryAction { + match (origin, header_flood) { + (RendererAuthorityOrigin::Owner, true) => StreamRecoveryAction::OwnerCmaf, + (RendererAuthorityOrigin::Delegated, true) => StreamRecoveryAction::FailClosed, + (_, false) => StreamRecoveryAction::FullDownload, + } +} + /// QConnect renderer engine backed by the daemon `AppRuntime`. Holds the shared /// runtime and forwards every trait method through `runtime.core()`; the async /// feeder spawns on the ambient tokio runtime (`start_track_stream` is always /// awaited from a runtime task). pub struct DaemonRendererEngine { runtime: Arc>, + catalog_authority: RendererCatalogAuthority, + authority: Arc, + stamp: AuthorityStamp, /// T10 (OD4): resolved volume policy for this session (from the KV at connect). volume_mode: VolumeMode, /// Live daemon `playback.quality` preference. The settings reload route /// updates this same cell, so an active QConnect session honors changes /// without reconnecting (#693). quality_cap: Arc>, + /// The progressive HTTP task currently feeding the player. Replacing a + /// track, stopping playback, or dropping this engine aborts the old task so + /// a retired authority cannot keep writing bytes in the background. + active_feeder: std::sync::Mutex>, } impl DaemonRendererEngine { + /// Compatibility alias for the pre-delegation owner constructor. New + /// integration code should call [`Self::owner`] or [`Self::delegated`] so + /// the credential origin is explicit at the construction boundary. pub fn new( runtime: Arc>, volume_mode: VolumeMode, quality_cap: Arc>, + authority: Arc, + stamp: AuthorityStamp, ) -> Self { - Self { + Self::owner(runtime, volume_mode, quality_cap, authority, stamp) + } + + pub fn owner( + runtime: Arc>, + volume_mode: VolumeMode, + quality_cap: Arc>, + authority: Arc, + stamp: AuthorityStamp, + ) -> Self { + Self::with_authority( runtime, + RendererCatalogAuthority::Owner, volume_mode, quality_cap, + authority, + stamp, + ) + } + + pub fn delegated( + runtime: Arc>, + delegated_client: Arc, + volume_mode: VolumeMode, + quality_cap: Arc>, + authority: Arc, + stamp: AuthorityStamp, + ) -> Self { + Self::with_authority( + runtime, + RendererCatalogAuthority::Delegated(delegated_client), + volume_mode, + quality_cap, + authority, + stamp, + ) + } + + fn with_authority( + runtime: Arc>, + catalog_authority: RendererCatalogAuthority, + volume_mode: VolumeMode, + quality_cap: Arc>, + authority: Arc, + stamp: AuthorityStamp, + ) -> Self { + assert!( + authority_origins_match(catalog_authority.origin(), stamp.origin()), + "renderer catalog authority must match its runtime authority stamp" + ); + Self { + runtime, + catalog_authority, + authority, + stamp, + volume_mode, + quality_cap, + active_feeder: std::sync::Mutex::new(None), } } + pub const fn authority_origin(&self) -> RendererAuthorityOrigin { + self.catalog_authority.origin() + } + + pub const fn authority_stamp(&self) -> AuthorityStamp { + self.stamp + } + + fn is_current(&self) -> bool { + self.authority.is_current(self.stamp) + && (!matches!(self.stamp.origin(), AuthorityOrigin::Owner) + || self.authority.owner_actions_allowed()) + } + + fn ensure_current(&self) -> Result<(), String> { + if self.is_current() { + Ok(()) + } else { + Err(RETIRED_AUTHORITY_ERROR.to_string()) + } + } + + fn action_permit(&self) -> Result { + self.authority + .try_runtime_action_permit(self.stamp) + .ok_or_else(|| RETIRED_AUTHORITY_ERROR.to_string()) + } + fn core(&self) -> &Arc> { self.runtime.core() } @@ -116,6 +368,24 @@ impl DaemonRendererEngine { clamp_quality(requested, cap) } + fn replace_active_feeder(&self, feeder: super::remote_stream::RemoteStreamFeeder) { + let previous = self + .active_feeder + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .replace(feeder); + drop(previous); + } + + fn cancel_active_feeder(&self) { + let feeder = self + .active_feeder + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + drop(feeder); + } + /// Last-resort load for tracks the raw-URL path cannot fetch (the CDN /// header flood defeats every reqwest attempt — see /// `remote_stream::is_header_flood_error`): the CMAF path is unaffected by @@ -124,37 +394,65 @@ impl DaemonRendererEngine { /// only fires on a playing->playing track edge), so sync it explicitly or /// `qbzd status` / the local now-playing truth keep showing the PREVIOUS /// track while the recovered one plays. - async fn play_via_cmaf( + async fn play_via_owner_cmaf( &self, track_id: u64, quality: Quality, start_position_secs: u64, ) -> Result<(), String> { - self.core() + let _permit = self.action_permit()?; + if self.authority_origin() != RendererAuthorityOrigin::Owner { + return Err(format!( + "delegated stream for track {track_id} cannot use owner CMAF fallback" + )); + } + let playback_result = self + .core() .play_track_resolved(track_id, quality, None, None, start_position_secs) - .await - .map_err(|err| format!("CMAF fallback for remote track {track_id}: {err}"))?; + .await; + self.ensure_current()?; + playback_result.map_err(|error| { + format!( + "CMAF fallback for remote track {track_id}: {}", + QconnectOwnerFailure::from_opaque_playback_error(&error) + ) + })?; self.core().sync_current_to_id(track_id).await; + self.ensure_current()?; Ok(()) } } +impl Drop for DaemonRendererEngine { + fn drop(&mut self) { + self.cancel_active_feeder(); + } +} + #[async_trait] impl QconnectRendererEngine for DaemonRendererEngine { // ---- transport (sync) ---- fn resume(&self) -> Result<(), String> { + let _permit = self.action_permit()?; self.core().resume().map_err(|err| err.to_string()) } fn pause(&self) -> Result<(), String> { + let _permit = self.action_permit()?; self.core().pause().map_err(|err| err.to_string()) } fn stop(&self) -> Result<(), String> { + self.cancel_active_feeder(); + let _permit = self.action_permit()?; self.core().stop().map_err(|err| err.to_string()) } fn seek(&self, position_secs: u64) -> Result<(), String> { - self.core().seek(position_secs).map_err(|err| err.to_string()) + let _permit = self.action_permit()?; + self.core() + .seek(position_secs) + .map_err(|err| err.to_string()) } fn set_volume(&self, fraction: f32) -> Result<(), String> { + let _permit = self.action_permit()?; // T10 (OD4, §7.4): volume-mode gate. In `Locked` mode the player stays // at 100 % and a controller's remote SetVolume is acknowledged-but- // ignored (logged at info), so the DAC keeps receiving full-scale, @@ -166,30 +464,52 @@ impl QconnectRendererEngine for DaemonRendererEngine { ); return Ok(()); } - self.core().set_volume(fraction).map_err(|err| err.to_string()) + self.core() + .set_volume(fraction) + .map_err(|err| err.to_string()) } fn get_playback_state(&self) -> PlaybackState { - self.core().get_playback_state() + if self.is_current() { + self.core().get_playback_state() + } else { + PlaybackState::default() + } } fn has_loaded_audio(&self) -> bool { - self.core().player().has_loaded_audio() + self.is_current() && self.core().player().has_loaded_audio() } // ---- queue / mode (async) ---- async fn set_repeat_mode(&self, mode: RepeatMode) { - self.core().set_repeat_mode(mode).await + if let Ok(_permit) = self.action_permit() { + self.core().set_repeat_mode(mode).await; + } } async fn set_shuffle(&self, enabled: bool) { - self.core().set_shuffle(enabled).await + if let Ok(_permit) = self.action_permit() { + self.core().set_shuffle(enabled).await; + } } async fn set_shuffle_flag(&self, enabled: bool) { - self.core().set_shuffle_with_order(enabled, None).await + if let Ok(_permit) = self.action_permit() { + self.core().set_shuffle_with_order(enabled, None).await; + } } async fn get_all_queue_tracks(&self) -> (Vec, Option) { - self.core().get_all_queue_tracks().await + if !self.is_current() { + return (Vec::new(), None); + } + let tracks = self.core().get_all_queue_tracks().await; + if self.is_current() { + tracks + } else { + (Vec::new(), None) + } } async fn set_queue(&self, tracks: Vec, start_index: Option) { - self.core().set_queue(tracks, start_index).await + if let Ok(_permit) = self.action_permit() { + self.core().set_queue(tracks, start_index).await; + } } async fn set_queue_with_order( &self, @@ -198,29 +518,41 @@ impl QconnectRendererEngine for DaemonRendererEngine { shuffle_enabled: bool, shuffle_order: Option>, ) { - self.core() - .set_queue_with_order(tracks, start_index, shuffle_enabled, shuffle_order) - .await + if let Ok(_permit) = self.action_permit() { + self.core() + .set_queue_with_order(tracks, start_index, shuffle_enabled, shuffle_order) + .await; + } } async fn clear_queue(&self, keep_current: bool) { - self.core().clear_queue(keep_current).await + if let Ok(_permit) = self.action_permit() { + self.core().clear_queue(keep_current).await; + } } async fn play_index(&self, index: usize) -> Option { - self.core().play_index(index).await + let _permit = self.action_permit().ok()?; + let track = self.core().play_index(index).await; + track } // ---- catalog (async) ---- async fn get_track(&self, track_id: u64) -> Result { - self.core() - .get_track(track_id) - .await - .map_err(|err| err.to_string()) + self.ensure_current()?; + let result = self + .catalog_authority + .get_track(self.core(), track_id) + .await; + self.ensure_current()?; + result } async fn get_tracks_batch(&self, track_ids: &[u64]) -> Result, String> { - self.core() - .get_tracks_batch(track_ids) - .await - .map_err(|err| err.to_string()) + self.ensure_current()?; + let result = self + .catalog_authority + .get_tracks_batch(self.core(), track_ids) + .await; + self.ensure_current()?; + result } // ---- protected audio seam (the only protected touch) ---- @@ -231,6 +563,8 @@ impl QconnectRendererEngine for DaemonRendererEngine { duration_secs: u64, start_position_secs: u64, ) -> Result<(), String> { + self.cancel_active_feeder(); + let _permit = self.action_permit()?; let quality = self.effective_quality(requested_quality); if quality != requested_quality { log::info!( @@ -239,11 +573,12 @@ impl QconnectRendererEngine for DaemonRendererEngine { quality ); } - let stream_url = self - .core() - .get_stream_url(track_id, quality) - .await - .map_err(|err| format!("resolve stream url for remote track {track_id}: {err}"))?; + let stream_url_result = self + .catalog_authority + .get_stream_url(self.core(), track_id, quality) + .await; + self.ensure_current()?; + let stream_url = stream_url_result?; let player = self.core().player(); let stream_result = super::remote_stream::stream_remote_track_into_player( @@ -253,24 +588,43 @@ impl QconnectRendererEngine for DaemonRendererEngine { start_position_secs, &stream_url.url, "QConnect", + || self.ensure_current(), ) .await; + self.ensure_current()?; - let Err(stream_err) = stream_result else { - return Ok(()); + let stream_err = match stream_result { + Ok(feeder) => { + self.ensure_current()?; + self.replace_active_feeder(feeder); + if let Err(error) = self.ensure_current() { + self.cancel_active_feeder(); + return Err(error); + } + return Ok(()); + } + Err(error) => error, }; - // Akamai small-object header flood: SMALL raw-url objects come back - // with ~106 headers, over hyper's hard-coded 100-header h1 cap, so - // EVERY reqwest fetch of this URL fails — the full download would die - // the same death. Skip it and go straight to the CMAF last resort. - if super::remote_stream::is_header_flood_error(&stream_err) { - log::warn!( - "[QConnect] Raw-URL streaming hit the CDN header flood for track {track_id}: {stream_err}. Skipping full download; last resort: CMAF." - ); - return self - .play_via_cmaf(track_id, quality, start_position_secs) - .await; + let header_flood = super::remote_stream::is_header_flood_error(&stream_err); + match recovery_action(self.authority_origin(), header_flood) { + StreamRecoveryAction::OwnerCmaf => { + log::warn!( + "[QConnect] Owner raw-URL streaming hit the CDN header limit for track {track_id}: {stream_err}. Skipping full download; last resort: CMAF." + ); + return self + .play_via_owner_cmaf(track_id, quality, start_position_secs) + .await; + } + StreamRecoveryAction::FailClosed => { + log::warn!( + "[QConnect] Delegated raw-URL streaming hit the CDN header limit for track {track_id}: {stream_err}. Owner and CMAF fallback are forbidden." + ); + return Err(format!( + "delegated CDN stream for track {track_id} failed: {stream_err}" + )); + } + StreamRecoveryAction::FullDownload => {} } log::warn!( @@ -280,17 +634,22 @@ impl QconnectRendererEngine for DaemonRendererEngine { ); match download_remote_audio(&stream_url.url).await { Ok(audio_data) => { - self.core() - .player() - .play_data(audio_data, track_id) - .map_err(|err| format!("play remote track {track_id}: {err}"))?; + self.ensure_current()?; + let playback_result = self.core().player().play_data(audio_data, track_id); + self.ensure_current()?; + playback_result.map_err(|err| format!("play remote track {track_id}: {err}"))?; Ok(()) } - Err(download_err) if super::remote_stream::is_header_flood_error(&download_err) => { + Err(download_err) + if recovery_action( + self.authority_origin(), + super::remote_stream::is_header_flood_error(&download_err), + ) == StreamRecoveryAction::OwnerCmaf => + { log::warn!( "[QConnect] Full download hit the CDN header flood for track {track_id}: {download_err}. Last resort: CMAF." ); - self.play_via_cmaf(track_id, quality, start_position_secs) + self.play_via_owner_cmaf(track_id, quality, start_position_secs) .await } Err(download_err) => Err(download_err), @@ -298,11 +657,11 @@ impl QconnectRendererEngine for DaemonRendererEngine { } fn current_output_format(&self) -> Option<(u32, u32)> { + if !self.is_current() { + return None; + } let player = self.core().player(); - Some(( - player.state.get_sample_rate(), - player.state.get_bit_depth(), - )) + Some((player.state.get_sample_rate(), player.state.get_bit_depth())) } } @@ -346,6 +705,52 @@ async fn download_remote_audio(url: &str) -> Result, String> { mod tests { use super::*; + const SIGNED_URL_MARKER: &str = + "https://api.example.test/track/get?request_ts=42&request_sig=SIGNED-URL-SECRET"; + const REMOTE_BODY_MARKER: &str = "REMOTE-BODY\nAuthorization: Bearer jwt_api-secret"; + + #[test] + fn owner_api_failure_categories_discard_signed_urls_and_remote_bodies() { + let errors = [ + ApiError::AuthenticationError(SIGNED_URL_MARKER.to_string()), + ApiError::Forbidden(format!(" : {REMOTE_BODY_MARKER}")), + ApiError::ApiResponse(format!("{REMOTE_BODY_MARKER} {SIGNED_URL_MARKER}")), + ]; + + for error in errors { + let diagnostic = classify_owner_api_failure(&error).to_string(); + assert!(!diagnostic.contains("SIGNED-URL-SECRET")); + assert!(!diagnostic.contains("REMOTE-BODY")); + assert!(!diagnostic.contains("Bearer")); + assert!(!diagnostic.contains("jwt_api-secret")); + assert!(!diagnostic.contains('\n')); + } + } + + #[test] + fn owner_core_and_opaque_playback_failures_are_payload_free() { + let core_error = CoreError::Api(ApiError::ApiResponse(format!( + "{SIGNED_URL_MARKER} {REMOTE_BODY_MARKER}" + ))); + let core_diagnostic = classify_owner_core_failure(&core_error).to_string(); + assert_eq!( + core_diagnostic, + QconnectOwnerFailure::InvalidResponse.to_string() + ); + assert!(!core_diagnostic.contains("SIGNED-URL-SECRET")); + assert!(!core_diagnostic.contains("REMOTE-BODY")); + + let opaque_error = format!("{SIGNED_URL_MARKER} {REMOTE_BODY_MARKER}"); + let playback_diagnostic = + QconnectOwnerFailure::from_opaque_playback_error(&opaque_error).to_string(); + assert_eq!( + playback_diagnostic, + QconnectOwnerFailure::Playback.to_string() + ); + assert!(!playback_diagnostic.contains("SIGNED-URL-SECRET")); + assert!(!playback_diagnostic.contains("REMOTE-BODY")); + } + #[test] fn software_mode_applies_and_reports_real() { // remote SetVolume 0.4 -> engine.set_volume(0.4); report reads real volume. @@ -396,4 +801,52 @@ mod tests { } } } + + #[test] + fn delegated_header_flood_fails_closed_without_owner_cmaf() { + assert_eq!( + recovery_action(RendererAuthorityOrigin::Delegated, true), + StreamRecoveryAction::FailClosed + ); + assert_ne!( + recovery_action(RendererAuthorityOrigin::Delegated, true), + StreamRecoveryAction::OwnerCmaf + ); + } + + #[test] + fn delegated_non_header_failure_only_reuses_the_delegated_cdn_url() { + assert_eq!( + recovery_action(RendererAuthorityOrigin::Delegated, false), + StreamRecoveryAction::FullDownload + ); + } + + #[test] + fn owner_retains_cmaf_recovery_for_the_known_header_limit() { + assert_eq!( + recovery_action(RendererAuthorityOrigin::Owner, true), + StreamRecoveryAction::OwnerCmaf + ); + } + + #[test] + fn catalog_origin_must_match_the_runtime_stamp_origin() { + assert!(authority_origins_match( + RendererAuthorityOrigin::Owner, + AuthorityOrigin::Owner + )); + assert!(authority_origins_match( + RendererAuthorityOrigin::Delegated, + AuthorityOrigin::Delegated { generation: 9 } + )); + assert!(!authority_origins_match( + RendererAuthorityOrigin::Owner, + AuthorityOrigin::Delegated { generation: 9 } + )); + assert!(!authority_origins_match( + RendererAuthorityOrigin::Delegated, + AuthorityOrigin::Owner + )); + } } diff --git a/crates/qbzd/src/qconnect/lan.rs b/crates/qbzd/src/qconnect/lan.rs new file mode 100644 index 000000000..40598f3db --- /dev/null +++ b/crates/qbzd/src/qconnect/lan.rs @@ -0,0 +1,526 @@ +//! Thin qbzd adapter for the official QConnect LAN receiver surface. +//! +//! This module projects the daemon's existing QConnect identity into the LAN +//! wire, owns the listener/mDNS lifetime, and drains the bounded latest-wins +//! admission slot on one dedicated thread. Cloud validation and transactional +//! authority switching deliberately remain outside this adapter. + +use std::error::Error; +use std::fmt; +use std::sync::{Arc, Mutex as StdMutex}; +use std::thread::JoinHandle; +use std::time::Duration; + +use qbz_models::Quality; +use qconnect_lan::{ + ConnectInfo, DeviceType, DisplayInfo, EndpointPolicy, HandoffCandidate, LanError, + LanProjection, LanService, LanServiceConfig, MaxAudioQuality, +}; + +use super::authority::{AuthorityCell, AuthorityOrigin, AuthorityStamp}; +use super::transport::default_qconnect_device_info; + +const ADMISSION_WAIT: Duration = Duration::from_secs(60); +const DAEMON_BRAND: &str = "QBZ"; +const DAEMON_MODEL: &str = "QBZ Daemon"; +const ADMISSION_THREAD_NAME: &str = "qbzd-qconnect-lan-admission"; + +pub type HandoffCallback = Arc; + +/// Stamp-aware bridge between runtime authority and the synchronous LAN +/// projection read by the HTTP worker. +/// +/// Every qbzd authority install/clear is serialized through this short-held +/// mutex. An old runtime event can therefore never overwrite the session of a +/// replacement runtime, and a prepared candidate is not exposed until the same +/// critical section that installs its authority. +#[derive(Clone, Default)] +pub(crate) struct DaemonLanProjectionSlot { + inner: Arc>, +} + +#[derive(Default)] +struct ProjectionSlotState { + projection: Option, + installed: Option, +} + +struct InstalledSessionProjection { + stamp: AuthorityStamp, + session_id: Option, +} + +impl DaemonLanProjectionSlot { + fn lock(&self) -> std::sync::MutexGuard<'_, ProjectionSlotState> { + self.inner.lock().unwrap_or_else(|poisoned| { + log::error!("[QConnect LAN] projection slot recovered from a poisoned lock"); + let guard = poisoned.into_inner(); + self.inner.clear_poison(); + guard + }) + } + + fn write_projection(state: &ProjectionSlotState, session_id: Option) { + if let Some(projection) = state.projection.as_ref() { + projection.set_current_session_id(session_id); + } + } + + /// Install a runtime and its already-confirmed public session atomically + /// with respect to every projection update. `session_id` is `None` for a + /// fresh owner until its first cloud SESSION_STATE arrives. + pub fn install_authority( + &self, + authority: &AuthorityCell, + stamp: AuthorityStamp, + session_id: Option<&str>, + ) -> bool { + let mut state = self.lock(); + + // Fail closed during the tiny install boundary: the old session may no + // longer be current, while exposing the candidate before install would + // violate the handoff transaction. + Self::write_projection(&state, None); + if !authority.install(stamp) { + let current = authority.current(); + let restore = state + .installed + .as_ref() + .filter(|installed| Some(installed.stamp) == current) + .and_then(|installed| installed.session_id.clone()); + if current != state.installed.as_ref().map(|installed| installed.stamp) { + state.installed = None; + } + Self::write_projection(&state, restore); + return false; + } + + let session_id = session_id.map(str::to_string); + state.installed = Some(InstalledSessionProjection { + stamp, + session_id: session_id.clone(), + }); + Self::write_projection(&state, session_id); + true + } + + /// Publish a cloud-confirmed owner session from an event belonging to the + /// exact installed runtime. Delegated session ids enter only through + /// `install_authority` after their transactional commit. + pub fn confirm_owner_session( + &self, + authority: &AuthorityCell, + stamp: AuthorityStamp, + session_id: &str, + ) -> bool { + if stamp.origin() != AuthorityOrigin::Owner || session_id.is_empty() { + return false; + } + + let mut state = self.lock(); + if !authority.is_current(stamp) + || state.installed.as_ref().map(|installed| installed.stamp) != Some(stamp) + { + return false; + } + + let session_id = session_id.to_string(); + if let Some(installed) = state.installed.as_mut() { + installed.session_id = Some(session_id.clone()); + } + Self::write_projection(&state, Some(session_id)); + true + } + + /// Snapshot the exact installed session for listener construction. This + /// seeds GET connect-info before the HTTP worker can accept its first call. + pub fn current_session_id( + &self, + authority: &AuthorityCell, + expected: AuthorityStamp, + ) -> Option { + let state = self.lock(); + if !authority.is_current(expected) { + return None; + } + state + .installed + .as_ref() + .filter(|installed| installed.stamp == expected) + .and_then(|installed| installed.session_id.clone()) + } + + pub fn attach(&self, authority: &AuthorityCell, projection: LanProjection) { + let mut state = self.lock(); + let current = authority.current(); + let session_id = state + .installed + .as_ref() + .filter(|installed| Some(installed.stamp) == current) + .and_then(|installed| installed.session_id.clone()); + projection.set_current_session_id(session_id); + state.projection = Some(projection); + } + + pub fn detach(&self) { + self.lock().projection.take(); + } + + pub fn clear_authority(&self, authority: &AuthorityCell) -> Option { + let mut state = self.lock(); + Self::write_projection(&state, None); + let previous = authority.clear(); + state.installed = None; + previous + } + + pub fn clear_if_current(&self, authority: &AuthorityCell, expected: AuthorityStamp) -> bool { + let mut state = self.lock(); + if !authority.is_current(expected) { + return false; + } + + Self::write_projection(&state, None); + if authority.clear_if_current(expected) { + state.installed = None; + true + } else { + let restore = state + .installed + .as_ref() + .filter(|installed| authority.is_current(installed.stamp)) + .and_then(|installed| installed.session_id.clone()); + Self::write_projection(&state, restore); + false + } + } +} + +#[derive(Debug)] +pub enum DaemonLanError { + ExistingIdentityUnavailable, + Service(LanError), + AdmissionThread(std::io::Error), +} + +impl fmt::Display for DaemonLanError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ExistingIdentityUnavailable => { + formatter.write_str("qconnect LAN identity is unavailable") + } + Self::Service(error) => write!(formatter, "qconnect LAN service failed: {error}"), + Self::AdmissionThread(_) => { + formatter.write_str("qconnect LAN admission thread could not start") + } + } + } +} + +impl Error for DaemonLanError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Service(error) => Some(error), + Self::AdmissionThread(error) => Some(error), + Self::ExistingIdentityUnavailable => None, + } + } +} + +impl From for DaemonLanError { + fn from(error: LanError) -> Self { + Self::Service(error) + } +} + +/// Map the daemon's effective Qobuz quality ceiling to the exact official LAN +/// enum. `UltraHiRes` is QBZ's 24-bit/>96 kHz tier and tops out at the Qobuz +/// 192 kHz format family; QBZ does not advertise the unused 384 kHz value. +pub const fn max_audio_quality_for_quality(quality: Quality) -> MaxAudioQuality { + match quality { + Quality::Mp3 => MaxAudioQuality::MP3, + Quality::Lossless => MaxAudioQuality::UpToCd, + Quality::HiRes => MaxAudioQuality::UpToHires96, + Quality::UltraHiRes => MaxAudioQuality::UpToHires192, + } +} + +struct ExistingIdentity { + device_uuid: String, + friendly_name: String, + software_version: String, +} + +impl ExistingIdentity { + fn resolve() -> Result { + let device_info = default_qconnect_device_info(); + let (Some(device_uuid), Some(friendly_name), Some(software_version)) = ( + device_info.device_uuid, + device_info.friendly_name, + device_info.software_version, + ) else { + return Err(DaemonLanError::ExistingIdentityUnavailable); + }; + if device_uuid.trim().is_empty() + || friendly_name.trim().is_empty() + || software_version.trim().is_empty() + { + return Err(DaemonLanError::ExistingIdentityUnavailable); + } + Ok(Self { + device_uuid, + friendly_name, + software_version, + }) + } +} + +fn build_projection( + identity: &ExistingIdentity, + app_id: String, + quality: Quality, + current_session_id: Option, +) -> LanProjection { + LanProjection::new( + DisplayInfo { + friendly_name: identity.friendly_name.clone(), + serial_number: identity.device_uuid.clone(), + brand_display_name: DAEMON_BRAND.to_string(), + model_display_name: DAEMON_MODEL.to_string(), + max_audio_quality: max_audio_quality_for_quality(quality), + device_type: DeviceType::Streamer, + software_version: identity.software_version.clone(), + }, + ConnectInfo { + app_id, + current_session_id, + }, + ) +} + +/// Owns the LAN service and its blocking admission bridge. +/// +/// The callback must enqueue or otherwise hand off the candidate promptly; it +/// runs serially on the dedicated bridge thread so callback invocation order is +/// the same order observed from the latest-wins admission slot. +pub struct DaemonLanRuntime { + service: Option, + projection: LanProjection, + admission_bridge: Option>, +} + +impl DaemonLanRuntime { + pub fn start( + endpoint_policy: EndpointPolicy, + app_id: String, + quality: Quality, + current_session_id: Option, + on_handoff: HandoffCallback, + ) -> Result { + let identity = ExistingIdentity::resolve()?; + let projection = build_projection(&identity, app_id, quality, current_session_id); + let config = + LanServiceConfig::new(projection.clone(), endpoint_policy, identity.device_uuid); + let (mut service, inbox) = LanService::start(config)?; + + let admission_bridge = match std::thread::Builder::new() + .name(ADMISSION_THREAD_NAME.to_string()) + .spawn(move || loop { + match inbox.take_timeout(ADMISSION_WAIT) { + Some(candidate) => on_handoff(candidate), + None if inbox.is_closed() => break, + None => {} + } + }) { + Ok(thread) => thread, + Err(error) => { + service.shutdown(); + return Err(DaemonLanError::AdmissionThread(error)); + } + }; + + Ok(Self { + service: Some(service), + projection, + admission_bridge: Some(admission_bridge), + }) + } + + pub fn projection(&self) -> LanProjection { + self.projection.clone() + } + + pub fn port(&self) -> Option { + self.service.as_ref().map(LanService::port) + } + + /// Idempotent teardown in the normative order: withdraw discovery and stop + /// HTTP admission first, then wait for the now-woken bridge to exit. + pub fn shutdown(&mut self) { + if let Some(mut service) = self.service.take() { + service.shutdown(); + } + if let Some(bridge) = self.admission_bridge.take() { + let _ = bridge.join(); + } + } +} + +impl Drop for DaemonLanRuntime { + fn drop(&mut self) { + self.shutdown(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture_identity() -> ExistingIdentity { + ExistingIdentity { + device_uuid: "de0c0d70-0000-4000-8000-000000000001".to_string(), + friendly_name: "Living room".to_string(), + software_version: "qbz/2.1.0".to_string(), + } + } + + #[test] + fn quality_mapping_matches_official_lan_families() { + assert_eq!( + max_audio_quality_for_quality(Quality::Mp3), + MaxAudioQuality::MP3 + ); + assert_eq!( + max_audio_quality_for_quality(Quality::Lossless), + MaxAudioQuality::UpToCd + ); + assert_eq!( + max_audio_quality_for_quality(Quality::HiRes), + MaxAudioQuality::UpToHires96 + ); + assert_eq!( + max_audio_quality_for_quality(Quality::UltraHiRes), + MaxAudioQuality::UpToHires192 + ); + } + + #[test] + fn daemon_projection_uses_existing_identity_and_official_role() { + let projection = build_projection( + &fixture_identity(), + "app-id".to_string(), + Quality::UltraHiRes, + None, + ); + + assert_eq!( + projection.display_info(), + DisplayInfo { + friendly_name: "Living room".to_string(), + serial_number: "de0c0d70-0000-4000-8000-000000000001".to_string(), + brand_display_name: "QBZ".to_string(), + model_display_name: "QBZ Daemon".to_string(), + max_audio_quality: MaxAudioQuality::UpToHires192, + device_type: DeviceType::Streamer, + software_version: "qbz/2.1.0".to_string(), + } + ); + assert_eq!( + projection.connect_info(), + ConnectInfo { + app_id: "app-id".to_string(), + current_session_id: None, + } + ); + } + + #[test] + fn projection_session_id_changes_only_when_explicitly_updated() { + let projection = build_projection( + &fixture_identity(), + "app-id".to_string(), + Quality::Lossless, + None, + ); + assert_eq!(projection.connect_info().current_session_id, None); + + projection.set_current_session_id(Some("delegated-session".to_string())); + assert_eq!( + projection.connect_info().current_session_id.as_deref(), + Some("delegated-session") + ); + projection.set_current_session_id(None); + assert_eq!(projection.connect_info().current_session_id, None); + } + + #[test] + fn stamped_projection_publishes_only_installed_authority() { + let authority = AuthorityCell::new(); + let slot = DaemonLanProjectionSlot::default(); + let projection = build_projection( + &fixture_identity(), + "app-id".to_string(), + Quality::Lossless, + None, + ); + slot.attach(&authority, projection.clone()); + + let owner = authority.reserve(AuthorityOrigin::Owner); + assert!(slot.install_authority(&authority, owner, None)); + assert!(slot.confirm_owner_session(&authority, owner, "owner-session")); + assert_eq!( + projection.connect_info().current_session_id.as_deref(), + Some("owner-session") + ); + + // Merely reserving/preparing a guest cannot expose its candidate id. + let guest = authority.reserve(AuthorityOrigin::Delegated { generation: 7 }); + assert_eq!( + projection.connect_info().current_session_id.as_deref(), + Some("owner-session") + ); + + assert!(slot.install_authority(&authority, guest, Some("guest-session"))); + assert_eq!( + projection.connect_info().current_session_id.as_deref(), + Some("guest-session") + ); + + // A late event from the retired owner cannot overwrite the guest. + assert!(!slot.confirm_owner_session(&authority, owner, "stale-owner-session")); + assert_eq!( + projection.connect_info().current_session_id.as_deref(), + Some("guest-session") + ); + } + + #[test] + fn owner_session_events_update_and_stale_clear_cannot_win() { + let authority = AuthorityCell::new(); + let slot = DaemonLanProjectionSlot::default(); + let projection = build_projection( + &fixture_identity(), + "app-id".to_string(), + Quality::Lossless, + None, + ); + slot.attach(&authority, projection.clone()); + + let first_owner = authority.reserve(AuthorityOrigin::Owner); + assert!(slot.install_authority(&authority, first_owner, None)); + assert!(slot.confirm_owner_session(&authority, first_owner, "owner-session-a")); + assert!(slot.confirm_owner_session(&authority, first_owner, "owner-session-b")); + assert_eq!( + slot.current_session_id(&authority, first_owner).as_deref(), + Some("owner-session-b") + ); + + let next_owner = authority.reserve(AuthorityOrigin::Owner); + assert!(slot.install_authority(&authority, next_owner, Some("owner-session-c"))); + assert!(!slot.clear_if_current(&authority, first_owner)); + assert_eq!( + projection.connect_info().current_session_id.as_deref(), + Some("owner-session-c") + ); + } +} diff --git a/crates/qbzd/src/qconnect/mod.rs b/crates/qbzd/src/qconnect/mod.rs index f7e968152..97c8703b4 100644 --- a/crates/qbzd/src/qconnect/mod.rs +++ b/crates/qbzd/src/qconnect/mod.rs @@ -18,7 +18,10 @@ //! with the bounded [2s, 5s, 15s, 30s] retry schedule. QConnect reads NOTHING //! from qbzd.toml — only the daemon-root `qconnect_settings.db`. +pub mod authority; +pub mod delegation; pub mod engine; +pub mod lan; pub mod publish; pub mod remote_stream; pub mod report; @@ -27,40 +30,67 @@ pub mod sink; pub mod transport; use std::path::PathBuf; -use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex as StdMutex, MutexGuard}; use std::time::Duration; use qbz_app::shell::AppRuntime; use qbz_models::CoreEvent; use qconnect_app::{ - compute_effective_startup, QconnectApp, QconnectAppEvent, QconnectEventSink, - QconnectLifecycleState, QconnectRemoteSyncState, QconnectSessionState, SessionLoopHost, + compute_effective_startup, CredentialOrigin, DelegationCoordinator, + DelegationCoordinatorConfig, DelegationErrorCode, DelegationHost, DelegationPhase, + DelegationSnapshot, QconnectApp, QconnectAppEvent, QconnectEnableIntent, QconnectEnableToken, + QconnectEventSink, QconnectLifecycleState, QconnectRemoteSyncState, SessionLoopHost, }; +use qconnect_lan::EndpointPolicy; use qconnect_transport_ws::{NativeWsTransport, WsTransportConfig}; -use tokio::sync::{broadcast, Mutex}; +use tokio::sync::{broadcast, Mutex as AsyncMutex}; use tokio::task::JoinHandle; use crate::adapter::DaemonAdapter; use crate::paths::ProfileRoots; use crate::state::{AuthState, DaemonShared}; +use self::authority::{AuthorityCell, AuthorityOrigin, AuthorityStamp}; +use self::delegation::{DaemonDelegationCoordinator, DaemonDelegationHost}; use self::engine::DaemonRendererEngine; +use self::lan::DaemonLanRuntime; use self::session::{bootstrap_remote_presence, DaemonSessionLoopHost}; use self::sink::{DaemonEventSink, DaemonQconnectApp}; type Runtime = Arc>; type SharedState = Arc>; +const QCONNECT_LAN_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3); +const QCONNECT_LAN_START_CLEANUP_TIMEOUT: Duration = Duration::from_secs(5); +const QCONNECT_COORDINATOR_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); +const QCONNECT_AUTHORITY_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3); + +async fn shutdown_lan_runtime(mut runtime: DaemonLanRuntime) -> Result<(), String> { + let mut shutdown = tokio::task::spawn_blocking(move || runtime.shutdown()); + match tokio::time::timeout(QCONNECT_LAN_SHUTDOWN_TIMEOUT, &mut shutdown).await { + Ok(Ok(())) => Ok(()), + Ok(Err(_)) => Err("qconnect-lan-shutdown-task-failed".to_string()), + Err(_) => { + // The detached blocking worker continues the idempotent shutdown; + // the daemon slot and projection have already been withdrawn. + drop(shutdown); + Err("qconnect-lan-shutdown-timed-out".to_string()) + } + } +} + /// The live QConnect runtime for one connected session (app + its config + the /// spawned event loop + the shared sync accumulator). pub(crate) struct DaemonQconnectRuntime { + pub stamp: AuthorityStamp, pub app: Arc, /// Re-latched by `bootstrap_after_reconnect` on a credential re-resolve; /// consumed by the full-reconnect path (T10) + status/endpoint reporting. #[allow(dead_code)] pub config: WsTransportConfig, pub event_loop: JoinHandle<()>, - pub sync_state: Arc>, + pub sync_state: Arc>, } /// Connect-flow state, mirrored on the desktop `SlintQconnectInner`. `pub(crate)` @@ -73,12 +103,23 @@ pub(crate) struct DaemonQconnectInner { #[allow(dead_code)] pub last_error: Option, pub lifecycle_state: QconnectLifecycleState, + /// Exact enabled intent that owns the pre-runtime `Connecting` claim. + pub connecting_token: Option, /// Last local queue ids this session pushed to the cloud (the publish.rs /// echo latch). Cleared on every connect (parity with the desktop /// `qconnect_service.rs` connect reset). pub last_pushed_queue_ids: Option>, } +pub(crate) fn lock_inner( + inner: &StdMutex, +) -> MutexGuard<'_, DaemonQconnectInner> { + inner.lock().unwrap_or_else(|poisoned| { + log::error!("[QConnect] runtime state recovered from a poisoned lock"); + poisoned.into_inner() + }) +} + /// Map a lifecycle state to the `/api/status` `qconnect.state` label + the /// session-active flag, and latch it into `DaemonShared`. fn latch_lifecycle_into_shared(shared: &SharedState, state: QconnectLifecycleState) { @@ -120,28 +161,42 @@ fn unix_seconds_string() -> String { /// the state actually changes. Mirrors the desktop /// `update_lifecycle_state_if_running`, plus the `DaemonShared` latch. pub(crate) async fn update_lifecycle_state_if_running( - inner: &Arc>, + inner: &Arc>, sink: &DaemonEventSink, shared: &SharedState, + authority: &AuthorityCell, + stamp: AuthorityStamp, next: QconnectLifecycleState, ) { - let mut guard = inner.lock().await; - if guard.runtime.is_none() { + if !authority.is_current(stamp) { return; } - if guard.lifecycle_state == next { + { + let mut guard = lock_inner(inner); + if guard.runtime.as_ref().map(|runtime| runtime.stamp) != Some(stamp) { + return; + } + if guard.lifecycle_state == next { + return; + } + guard.lifecycle_state = next; + } + if !authority.is_current(stamp) { return; } - guard.lifecycle_state = next; - drop(guard); latch_lifecycle_into_shared(shared, next); + if !authority.is_current(stamp) { + return; + } sink.on_event(QconnectAppEvent::LifecycleChanged { state: next }) .await; } /// The headless QConnect connect service. pub struct DaemonQconnectService { - inner: Arc>, + inner: Arc>, + authority: Arc, + enable_intent: Arc, runtime: Runtime, shared: SharedState, #[allow(dead_code)] // T11 (settings reload) re-reads the KV through this path. @@ -151,79 +206,641 @@ pub struct DaemonQconnectService { /// reads it for every remote stream start so QConnect cannot bypass the /// daemon's configured maximum quality (#693). quality_cap: Arc>, + delegation_host: Arc, + coordinator: DaemonDelegationCoordinator, + lan: AsyncMutex>, + /// A cancelled blocking start remains physically live until its result is + /// joined and any listener it created is shut down. + lan_start_pending: Arc, + /// Permanent fail-closed fence for a LAN worker whose physical teardown + /// could not be confirmed. + lan_teardown_unsafe: Arc, + lifecycle_gate: AsyncMutex<()>, + teardown_incomplete: AtomicBool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct QconnectDisconnectOutcome { + pub authority_safe: bool, + pub owner_restored: bool, + pub lan_withdrawn: bool, } impl DaemonQconnectService { - /// Establish the QConnect session. Gated on an initialized API client (the - /// qws/createToken discovery needs it). Idempotent: a second call while a - /// runtime is alive (or a connect is in flight) is a no-op. - pub async fn connect(&self) -> Result<(), String> { - if !self.runtime.core().is_api_initialized().await { - return Err("Qobuz API is not initialized; cannot start Qobuz Connect".to_string()); - } - - // Claim the connect slot ATOMICALLY before the transport-config await, so - // two concurrent connect()s can't both build a runtime. A live runtime OR - // an in-flight `Connecting` both short-circuit to a no-op. - { - let mut guard = self.inner.lock().await; - if guard.runtime.is_some() - || guard.lifecycle_state == QconnectLifecycleState::Connecting - { - log::info!( - "[QConnect] connect() called while already {:?}; no-op", - guard.lifecycle_state - ); - return Ok(()); + async fn await_while_enabled( + &self, + enable_token: QconnectEnableToken, + future: impl std::future::Future, + ) -> Result { + let value = tokio::select! { + biased; + _ = self.enable_intent.cancelled(enable_token) => { + return Err("Qobuz Connect was disabled".to_string()); } - guard.lifecycle_state = QconnectLifecycleState::Connecting; - guard.last_error = None; - } - latch_lifecycle_into_shared(&self.shared, QconnectLifecycleState::Connecting); + value = future => value, + }; + self.enable_intent + .is_current(enable_token) + .then_some(value) + .ok_or_else(|| "Qobuz Connect was disabled".to_string()) + } - let config = match transport::resolve_transport_config(&self.runtime).await { - Ok(config) => config, - Err(err) => { - let mut guard = self.inner.lock().await; - if guard.runtime.is_none() { - guard.lifecycle_state = QconnectLifecycleState::Off; + fn set_lan_status(&self, state: &str, port: Option, error: Option<&str>) { + if let Ok(mut shared) = self.shared.lock() { + shared.qconnect.lan_state = state.to_string(); + shared.qconnect.lan_port = port; + shared.qconnect.last_lan_error = error.map(str::to_string); + shared.emit_qconnect_session_changed(); + } + } + + fn set_enabled_status(&self, enabled: bool) { + if let Ok(mut shared) = self.shared.lock() { + if shared.qconnect.enabled != enabled { + shared.qconnect.enabled = enabled; + shared.emit_qconnect_session_changed(); + } + } + } + + async fn owner_app_id(&self) -> Result { + let client = self + .runtime + .core() + .client() + .read() + .await + .clone() + .ok_or_else(|| "qconnect-lan-owner-client-unavailable".to_string())?; + client + .app_id() + .await + .map_err(|_| "qconnect-lan-app-id-unavailable".to_string()) + } + + async fn start_lan( + &self, + enable_token: QconnectEnableToken, + stamp: AuthorityStamp, + qws_endpoint: &str, + ) -> Result<(), String> { + if self.lan_teardown_unsafe.load(Ordering::Acquire) { + return Err("qconnect-lan-physical-teardown-unsafe".to_string()); + } + if !self.enable_intent.is_current(enable_token) { + return Err("qconnect-lan-disabled".to_string()); + } + if self.lan.lock().await.is_some() { + return self + .enable_intent + .is_current(enable_token) + .then_some(()) + .ok_or_else(|| "qconnect-lan-disabled".to_string()); + } + if !self.enable_intent.is_current(enable_token) || !self.authority.is_current(stamp) { + return Err("qconnect-lan-owner-superseded".to_string()); + } + + if self + .enable_intent + .commit_if_current(enable_token, || self.set_lan_status("binding", None, None)) + .is_none() + { + return Err("qconnect-lan-disabled".to_string()); + } + let endpoint_policy = + EndpointPolicy::from_trusted_endpoints(qbz_qobuz::endpoints::BASE_URL, qws_endpoint) + .map_err(|_| "qconnect-lan-endpoint-policy-invalid".to_string())?; + let app_id = self + .await_while_enabled(enable_token, self.owner_app_id()) + .await??; + if !self.enable_intent.is_current(enable_token) { + return Err("qconnect-lan-disabled".to_string()); + } + let quality = *self + .quality_cap + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let current_session_id = self + .delegation_host + .current_projected_session_id(stamp) + .ok_or_else(|| "qconnect-lan-owner-session-unavailable".to_string())?; + if !self.enable_intent.is_current(enable_token) || !self.authority.is_current(stamp) { + return Err("qconnect-lan-owner-superseded".to_string()); + } + let coordinator = self.coordinator.clone(); + let callback_intent = Arc::clone(&self.enable_intent); + let runtime_handle = tokio::runtime::Handle::current(); + let callback = Arc::new(move |candidate| { + if !callback_intent.is_current(enable_token) { + return; + } + let result = runtime_handle.block_on(coordinator.admit(candidate)); + match result { + Ok(generation) => { + log::info!("[QConnect LAN] handoff admitted generation={generation}"); } - drop(guard); - latch_lifecycle_into_shared(&self.shared, QconnectLifecycleState::Off); - return Err(err); + Err(error) => { + log::warn!("[QConnect LAN] handoff admission rejected: {error}"); + } + } + }); + + if self.lan_start_pending.swap(true, Ordering::AcqRel) { + return Err("qconnect-lan-start-already-pending".to_string()); + } + let mut start_task = tokio::task::spawn_blocking(move || { + DaemonLanRuntime::start( + endpoint_policy, + app_id, + quality, + Some(current_session_id), + callback, + ) + }); + let start_result = tokio::select! { + biased; + _ = self.enable_intent.cancelled(enable_token) => { + let pending = Arc::clone(&self.lan_start_pending); + let unsafe_latch = Arc::clone(&self.lan_teardown_unsafe); + tokio::spawn(async move { + let cleanup_safe = match start_task.await { + Ok(Ok(runtime)) => match shutdown_lan_runtime(runtime).await { + Ok(()) => true, + Err(error) => { + log::warn!( + "[QConnect LAN] cancelled listener cleanup failed: {error}" + ); + false + } + }, + Ok(Err(_)) => true, + Err(error) => { + log::warn!( + "[QConnect LAN] cancelled listener start worker failed: {error}" + ); + false + } + }; + if !cleanup_safe { + unsafe_latch.store(true, Ordering::Release); + } + pending.store(false, Ordering::Release); + }); + return Err("qconnect-lan-disabled".to_string()); + } + result = &mut start_task => result, + }; + self.lan_start_pending.store(false, Ordering::Release); + let started = match start_result { + Ok(Ok(runtime)) => runtime, + Ok(Err(_)) => return Err("qconnect-lan-bind-failed".to_string()), + Err(_) => { + self.lan_teardown_unsafe.store(true, Ordering::Release); + return Err("qconnect-lan-start-task-failed".to_string()); } }; + if !self.enable_intent.is_current(enable_token) || !self.authority.is_current(stamp) { + if let Err(error) = shutdown_lan_runtime(started).await { + log::warn!("[QConnect LAN] stale listener teardown failed: {error}"); + self.lan_teardown_unsafe.store(true, Ordering::Release); + } + return Err("qconnect-lan-owner-superseded".to_string()); + } + + let port = started.port(); + let projection = started.projection(); + let mut pending = Some(started); + let mut lan = self.lan.lock().await; + let installed = self + .enable_intent + .commit_if_current(enable_token, || { + if !self.authority.is_current(stamp) || lan.is_some() { + return false; + } + self.delegation_host.attach_projection(projection); + *lan = pending.take(); + self.set_lan_status("listening", port, None); + true + }) + .unwrap_or(false); + drop(lan); + if installed { + return Ok(()); + } + + if let Some(stale) = pending { + if let Err(error) = shutdown_lan_runtime(stale).await { + log::warn!("[QConnect LAN] rejected listener teardown failed: {error}"); + self.lan_teardown_unsafe.store(true, Ordering::Release); + } + } + Err("qconnect-lan-disabled-or-owner-superseded".to_string()) + } + + async fn stop_lan(&self) -> Result<(), String> { + self.set_lan_status("off", None, None); + self.delegation_host.detach_projection(); + let runtime = self.lan.lock().await.take(); + if let Some(runtime) = runtime { + if let Err(error) = shutdown_lan_runtime(runtime).await { + self.lan_teardown_unsafe.store(true, Ordering::Release); + return Err(error); + } + } + if self.lan_start_pending.load(Ordering::Acquire) { + let settled = tokio::time::timeout(QCONNECT_LAN_START_CLEANUP_TIMEOUT, async { + while self.lan_start_pending.load(Ordering::Acquire) { + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .is_ok(); + if !settled { + return Err("qconnect-lan-start-cleanup-timed-out".to_string()); + } + } + if self.lan_teardown_unsafe.load(Ordering::Acquire) { + return Err("qconnect-lan-physical-teardown-unsafe".to_string()); + } + Ok(()) + } + + async fn wait_for_owner_session_ready( + &self, + enable_token: QconnectEnableToken, + stamp: AuthorityStamp, + ) -> Result<(), String> { + const OWNER_SESSION_TIMEOUT: Duration = Duration::from_secs(30); + let wait = async { + loop { + if !self.enable_intent.is_current(enable_token) { + return Err("qconnect disabled before session acceptance".into()); + } + if !self.authority.is_current(stamp) { + return Err("qconnect owner authority changed before session acceptance".into()); + } + let lifecycle = lock_inner(&self.inner).lifecycle_state; + match lifecycle { + QconnectLifecycleState::Connected => return Ok(()), + QconnectLifecycleState::Off | QconnectLifecycleState::Exhausted => { + return Err(format!( + "qconnect owner session failed before acceptance ({lifecycle:?})" + )); + } + _ => { + if !self.enable_intent.is_current(enable_token) { + return Err("qconnect disabled before session acceptance".into()); + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + } + } + }; + tokio::time::timeout(OWNER_SESSION_TIMEOUT, wait) + .await + .map_err(|_| "qconnect owner session acceptance timed out".to_string())? + } + + /// Retire only the initial owner runtime installed by one connect attempt. + /// A late failed attempt must not invalidate enabled intent or tear down a + /// newer owner/delegated runtime. + async fn retire_initial_owner_if_current( + &self, + enable_token: QconnectEnableToken, + stamp: AuthorityStamp, + error: Option, + ) { + let mut error = error; + let current_retirement = self.enable_intent.commit_if_current(enable_token, || { + let runtime = { + let mut inner = lock_inner(&self.inner); + if inner.runtime.as_ref().map(|runtime| runtime.stamp) != Some(stamp) { + return None; + } + self.delegation_host + .projection_slot() + .clear_if_current(&self.authority, stamp); + inner.lifecycle_state = QconnectLifecycleState::Off; + inner.last_pushed_queue_ids = None; + if let Some(error) = error.take() { + inner.last_error = Some(error); + } + inner.runtime.take() + }; + if runtime.is_some() { + latch_lifecycle_into_shared(&self.shared, QconnectLifecycleState::Off); + } + runtime + }); + let runtime = match current_retirement { + Some(runtime) => runtime, + None => { + // Disabled intent may already be tearing this stamp down. Help + // retire it exactly, but never publish its stale error/status. + let mut inner = lock_inner(&self.inner); + if inner.runtime.as_ref().map(|runtime| runtime.stamp) != Some(stamp) { + return; + } + self.delegation_host + .projection_slot() + .clear_if_current(&self.authority, stamp); + inner.lifecycle_state = QconnectLifecycleState::Off; + inner.last_pushed_queue_ids = None; + inner.runtime.take() + } + }; + + if let Some(runtime) = runtime { + runtime.event_loop.abort(); + { + let mut sync = runtime.sync_state.lock().await; + sync.watchdog_generation = sync.watchdog_generation.wrapping_add(1); + sync.session = qconnect_app::QconnectSessionState::default(); + sync.session_renderer_states.clear(); + } + let _ = runtime.app.disconnect().await; + let _ = runtime.event_loop.await; + } + } + + fn record_connect_error_if_current(&self, enable_token: QconnectEnableToken, message: String) { + let _ = self.enable_intent.commit_if_current(enable_token, || { + let recorded = { + let mut inner = lock_inner(&self.inner); + if inner.runtime.is_some() { + return false; + } + inner.lifecycle_state = QconnectLifecycleState::Off; + inner.connecting_token = None; + inner.last_error = Some(message); + true + }; + if recorded { + latch_lifecycle_into_shared(&self.shared, QconnectLifecycleState::Off); + } + recorded + }); + } + + async fn disconnect_with_owner_policy( + &self, + restore_owner: bool, + ) -> Result { + // Cancellation must be observable by a connect that currently owns + // the lifecycle gate. Waiting for that connect before invalidating its + // token would turn disable into a full bootstrap/session timeout. + self.enable_intent.disable(); + let _ = self + .enable_intent + .commit_if_disabled(|| self.set_enabled_status(false)); + { + let mut inner = lock_inner(&self.inner); + inner.connecting_token = None; + if inner.runtime.is_none() { + inner.lifecycle_state = QconnectLifecycleState::Off; + } + } + let _lifecycle = self.lifecycle_gate.lock().await; + if self.enable_intent.current_token().is_some() { + return Err("qconnect-disable-superseded-by-enable".to_string()); + } + self.disconnect_with_owner_policy_locked(restore_owner).await + } + + async fn disconnect_with_owner_policy_locked( + &self, + restore_owner: bool, + ) -> Result { + // The public entry invalidated enabled intent before waiting for this + // lane. Do not invalidate again here: an enable requested while that + // wait was pending is newer and must win. + { + let mut inner = lock_inner(&self.inner); + inner.connecting_token = None; + if inner.runtime.is_none() { + inner.lifecycle_state = QconnectLifecycleState::Off; + } + } + let lan_withdrawn = if let Err(error) = self.stop_lan().await { + log::warn!("[QConnect] LAN teardown failed: {error}"); + false + } else { + !self.lan_start_pending.load(Ordering::Acquire) + && !self.lan_teardown_unsafe.load(Ordering::Acquire) + }; + // A tracked restore from the previous authority transition owns the + // same gate coordinator shutdown needs. Observe its bounded result + // first so the shorter shutdown timeout cannot detach a late owner + // runtime that starts after enabled intent was cleared. + let prior_owner_restore = self + .delegation_host + .await_owner_playback_restore() + .await; + self.delegation_host + .set_shutdown_restore_owner(restore_owner); + let coordinator_stopped = if tokio::time::timeout( + QCONNECT_COORDINATOR_SHUTDOWN_TIMEOUT, + self.coordinator.shutdown(), + ) + .await + .is_err() + { + log::warn!("[QConnect] qconnect-coordinator-shutdown-timed-out"); + false + } else { + true + }; + // `shutdown()` is intentionally a no-op while the coordinator is still + // Disabled (for example, bootstrap failed before OwnerReady). The host + // teardown is idempotent and closes that pre-OwnerReady window too. + let direct_authority_shutdown_needed = !coordinator_stopped + || self.authority.current().is_some() + || lock_inner(&self.inner).runtime.is_some() + || self.delegation_host.owner_snapshot_pending(); + let authority_stopped = if direct_authority_shutdown_needed { + if tokio::time::timeout( + QCONNECT_AUTHORITY_SHUTDOWN_TIMEOUT, + self.delegation_host.shutdown_authority(), + ) + .await + .is_err() + { + log::warn!("[QConnect] qconnect-authority-shutdown-timed-out"); + false + } else { + true + } + } else { + true + }; + + let shutdown_owner_restore = self + .delegation_host + .await_owner_playback_restore() + .await; + let owner_restored = prior_owner_restore.is_ok() + && shutdown_owner_restore.is_ok() + && !self.delegation_host.owner_restore_pending(); + let runtime_removed = lock_inner(&self.inner).runtime.is_none(); + let authority_safe = lan_withdrawn + && coordinator_stopped + && authority_stopped + && runtime_removed + && self.authority.current().is_none(); + self.teardown_incomplete + .store(!authority_safe, Ordering::Release); + + if authority_safe { + if let Ok(mut shared) = self.shared.lock() { + shared.qconnect.state = "off".to_string(); + shared.qconnect.session_active = false; + shared.qconnect.credential_origin = "owner".to_string(); + shared.qconnect.candidate_generation = None; + shared.emit_qconnect_session_changed(); + } + } + let outcome = QconnectDisconnectOutcome { + authority_safe, + owner_restored, + lan_withdrawn, + }; + if authority_safe { + Ok(outcome) + } else { + Err("qconnect-authority-teardown-incomplete".to_string()) + } + } + + /// Establish the QConnect session. Gated on an initialized API client (the + /// qws/createToken discovery needs it). Idempotent while a runtime is live. + pub async fn connect(&self) -> Result<(), String> { + let enable_token = self.enable_intent.enable(); + let _ = self + .enable_intent + .commit_if_current(enable_token, || self.set_enabled_status(true)); + self.connect_with_token(enable_token).await + } + + async fn connect_with_token(&self, enable_token: QconnectEnableToken) -> Result<(), String> { + let _lifecycle = self + .await_while_enabled(enable_token, self.lifecycle_gate.lock()) + .await?; + if !self.enable_intent.is_current(enable_token) { + return Err("Qobuz Connect was disabled".to_string()); + } + if self.teardown_incomplete.load(Ordering::Acquire) { + return Err("qconnect-authority-teardown-incomplete".to_string()); + } + let prior_restore = self + .await_while_enabled( + enable_token, + self.delegation_host.await_owner_playback_restore(), + ) + .await?; + if let Err(error) = prior_restore { + let message = format!("qconnect owner rollback incomplete: {error}"); + self.record_connect_error_if_current(enable_token, message.clone()); + return Err(message); + } + let api_initialized = self + .await_while_enabled(enable_token, self.runtime.core().is_api_initialized()) + .await?; + if !api_initialized { + return Err("Qobuz API is not initialized; cannot start Qobuz Connect".to_string()); + } + + let claimed = self + .enable_intent + .commit_if_current(enable_token, || { + let mut guard = lock_inner(&self.inner); + if guard.runtime.is_some() { + return Ok(false); + } + if guard.connecting_token == Some(enable_token) { + return Err("QConnect connect is already in progress".to_string()); + } + guard.connecting_token = Some(enable_token); + guard.lifecycle_state = QconnectLifecycleState::Connecting; + guard.last_error = None; + Ok(true) + }) + .ok_or_else(|| "Qobuz Connect was disabled".to_string())??; + if !claimed { + return Ok(()); + } + let _ = self.enable_intent.commit_if_current(enable_token, || { + latch_lifecycle_into_shared(&self.shared, QconnectLifecycleState::Connecting) + }); + + let config = match self + .await_while_enabled(enable_token, transport::resolve_transport_config(&self.runtime)) + .await + { + Ok(Ok(config)) => config, + Ok(Err(err)) => { + self.record_connect_error_if_current(enable_token, err.clone()); + return Err(err); + } + Err(err) => return Err(err), + }; + if !self.enable_intent.is_current(enable_token) { + return Err("Qobuz Connect was disabled".to_string()); + } + + let qws_endpoint = config.endpoint_url.clone(); + let stamp = self.authority.reserve(AuthorityOrigin::Owner); + let projection = self.delegation_host.projection_slot(); let transport = Arc::new(NativeWsTransport::new()); - let sync_state = Arc::new(Mutex::new(QconnectRemoteSyncState::default())); + let sync_state = Arc::new(AsyncMutex::new(QconnectRemoteSyncState::default())); // T10 (OD4, §7.4): resolve the volume policy from the daemon-root KV at // connect, so a later `qbzd settings reload` (T11) is picked up on the // next connect. Unset/unknown -> Software (the OD4 default). - let volume_mode = - engine::VolumeMode::from_kv(transport::load_volume_mode_at(&self.settings_db).as_deref()); + let volume_mode = engine::VolumeMode::from_kv( + transport::load_volume_mode_at(&self.settings_db).as_deref(), + ); let engine = DaemonRendererEngine::new( Arc::clone(&self.runtime), volume_mode, Arc::clone(&self.quality_cap), + Arc::clone(&self.authority), + stamp, ); - let sink = Arc::new(DaemonEventSink::new(engine, Arc::clone(&sync_state))); + let sink = Arc::new(DaemonEventSink::new( + engine, + Arc::clone(&sync_state), + Arc::clone(&self.authority), + stamp, + projection.clone(), + )); let app = Arc::new(QconnectApp::new( Arc::clone(&transport), Arc::clone(&sink), Arc::clone(&sync_state), )); - // Wire the owning app into the sink so it can emit reports + drive - // session-apply. sink.set_app(&app); - if let Err(err) = app.connect(config.clone()).await { - let mut guard = self.inner.lock().await; - guard.lifecycle_state = QconnectLifecycleState::Off; - let msg = format!("qconnect transport connect failed: {err}"); - guard.last_error = Some(msg.clone()); - drop(guard); - latch_lifecycle_into_shared(&self.shared, QconnectLifecycleState::Off); - return Err(msg); + // The transport broadcast has no replay; subscribe before connect so + // Connected/Auth/Subscribed cannot race past this runtime. + let transport_rx = app.subscribe_transport_events(); + + let app_connect = tokio::select! { + biased; + _ = self.enable_intent.cancelled(enable_token) => { + let _ = app.disconnect().await; + return Err("Qobuz Connect was disabled during transport connect".to_string()); + } + result = app.connect(config.clone()) => result, + }; + if let Err(err) = app_connect { + let message = format!("qconnect transport connect failed: {err}"); + self.record_connect_error_if_current(enable_token, message.clone()); + return Err(message); + } + if !self.enable_intent.is_current(enable_token) { + let _ = app.disconnect().await; + return Err("Qobuz Connect was disabled during transport connect".to_string()); } // T10 (OD4, §7.4): if locked volume mode, pin the player to 100% (1.0) @@ -235,94 +852,167 @@ impl DaemonQconnectService { log::warn!("[QConnect] failed to pin volume to 100% at connect: {err}"); } } + if !self.enable_intent.is_current(enable_token) { + let _ = app.disconnect().await; + return Err("Qobuz Connect was disabled before owner install".to_string()); + } - // Subscribe to transport events SYNCHRONOUSLY here — after connect() - // returns and BEFORE the spawn / any further await — so the receiver is - // live before the WS handshake emits Connected / Subscribed / - // SessionEstablished / SESSION_STATE. tokio broadcast has no replay; a - // receiver created inside the spawned loop would race + drop those. - let transport_rx = app.subscribe_transport_events(); let idle_retry_active = config.reconnect_idle_retry_ms > 0; let host: Arc = Arc::new(DaemonSessionLoopHost { app: Arc::clone(&app), sync_state: Arc::clone(&sync_state), inner: Arc::clone(&self.inner), + authority: Arc::clone(&self.authority), + stamp, sink: Arc::clone(&sink), runtime: Arc::clone(&self.runtime), shared: Arc::clone(&self.shared), - volume_mode, // T10 (OD4): join-time volume report honors the mode + volume_mode, + projection: projection.clone(), }); - let app_for_loop = Arc::clone(&app); - let event_loop = tokio::spawn(async move { - app_for_loop - .run_session_loop(host, transport_rx, idle_retry_active) - .await; - }); - - let runtime_app = Arc::clone(&app); - { - let mut guard = self.inner.lock().await; - guard.last_error = None; - guard.last_pushed_queue_ids = None; // fresh session: re-arm the publish echo latch - guard.runtime = Some(DaemonQconnectRuntime { - app, - config, - event_loop, - sync_state, - }); + let mut host = Some(host); + let mut transport_rx = Some(transport_rx); + let installed = self + .enable_intent + .commit_if_current(enable_token, || { + let mut guard = lock_inner(&self.inner); + if guard.runtime.is_some() || guard.connecting_token != Some(enable_token) { + return false; + } + if !projection.install_authority(&self.authority, stamp, None) { + guard.connecting_token = None; + guard.lifecycle_state = QconnectLifecycleState::Off; + return false; + } + let app_for_loop = Arc::clone(&app); + let host = host.take().expect("connect host consumed once"); + let transport_rx = transport_rx.take().expect("connect receiver consumed once"); + let event_loop = tokio::spawn(async move { + app_for_loop + .run_session_loop(host, transport_rx, idle_retry_active) + .await; + }); + guard.last_error = None; + guard.last_pushed_queue_ids = None; + guard.connecting_token = None; + guard.runtime = Some(DaemonQconnectRuntime { + stamp, + app: Arc::clone(&app), + config: config.clone(), + event_loop, + sync_state: Arc::clone(&sync_state), + }); + true + }) + .unwrap_or(false); + if !installed { + let error = "qconnect owner install was disabled or superseded".to_string(); + self.record_connect_error_if_current(enable_token, error.clone()); + let _ = app.disconnect().await; + return Err(error); } let custom_name = self.custom_device_name.read().await.clone(); - if let Err(err) = bootstrap_remote_presence(&runtime_app, custom_name.clone()).await { - let _ = self.disconnect().await; - let mut guard = self.inner.lock().await; - guard.last_error = Some(format!("qconnect bootstrap failed: {err}")); - return Err(format!("qconnect bootstrap failed: {err}")); + let bootstrap = tokio::select! { + biased; + _ = self.enable_intent.cancelled(enable_token) => { + let error = "Qobuz Connect was disabled during bootstrap".to_string(); + self.retire_initial_owner_if_current(enable_token, stamp, None).await; + return Err(error); + } + result = bootstrap_remote_presence( + &app, + custom_name.clone(), + &self.authority, + stamp, + ) => result, + }; + if let Err(err) = bootstrap { + let error = format!("qconnect bootstrap failed: {err}"); + self.retire_initial_owner_if_current(enable_token, stamp, Some(error.clone())) + .await; + return Err(error); + } + if !self.enable_intent.is_current(enable_token) || !self.authority.is_current(stamp) { + let error = "qconnect owner authority changed during bootstrap".to_string(); + self.retire_initial_owner_if_current(enable_token, stamp, Some(error.clone())) + .await; + return Err(error); + } + if let Err(error) = self.wait_for_owner_session_ready(enable_token, stamp).await { + self.retire_initial_owner_if_current(enable_token, stamp, Some(error.clone())) + .await; + return Err(error); } - // Reflect the resolved device name in `/api/status`. + // Reflect the resolved device name in `/api/status` while this exact + // enabled intent is still current. let effective_name = transport::resolve_qconnect_friendly_name(custom_name.as_deref()); - if let Ok(mut s) = self.shared.lock() { - s.qconnect.device_name = effective_name; + let _ = self.enable_intent.commit_if_current(enable_token, || { + if let Ok(mut shared) = self.shared.lock() { + shared.qconnect.device_name = effective_name; + shared.qconnect.credential_origin = "owner".to_string(); + } + }); + + let owner_ready = self + .await_while_enabled( + enable_token, + self.coordinator + .declare_owner_ready_if(|| self.enable_intent.is_current(enable_token)), + ) + .await + .unwrap_or(false); + if !owner_ready { + if self.enable_intent.is_current(enable_token) { + self.coordinator.shutdown().await; + } + let error = "qconnect delegation coordinator did not enter OwnerReady".to_string(); + self.retire_initial_owner_if_current(enable_token, stamp, Some(error.clone())) + .await; + return Err(error); + } + if !self.enable_intent.is_current(enable_token) { + let error = "Qobuz Connect was disabled before LAN startup".to_string(); + self.retire_initial_owner_if_current(enable_token, stamp, Some(error.clone())) + .await; + return Err(error); + } + if let Err(error) = self.start_lan(enable_token, stamp, &qws_endpoint).await { + if !self.enable_intent.is_current(enable_token) || !self.authority.is_current(stamp) { + self.retire_initial_owner_if_current(enable_token, stamp, Some(error.clone())) + .await; + return Err(error); + } + let _ = self.enable_intent.commit_if_current(enable_token, || { + log::warn!("[QConnect LAN] listener unavailable: {error}"); + self.set_lan_status("error", None, Some(&error)); + }); } - Ok(()) + self.enable_intent + .is_current(enable_token) + .then_some(()) + .ok_or_else(|| "Qobuz Connect was disabled during connect".to_string()) } /// Tear the QConnect session down. Always forces Off. Aborts AND joins the /// event loop so its `Arc` clone drops before the daemon's /// shutdown releases the audio device (§8.2 / #521 ordering). pub async fn disconnect(&self) -> Result<(), String> { - let runtime = { - let mut guard = self.inner.lock().await; - guard.lifecycle_state = QconnectLifecycleState::Off; - guard.runtime.take() - }; + self.disconnect_safely().await.map(|_| ()) + } - if let Some(runtime) = runtime { - // Disarm any in-flight liveness watchdog + clear the session topology - // BEFORE aborting the loop, so a late event can't resurrect a stale - // active-renderer state. - { - let mut state = runtime.sync_state.lock().await; - state.watchdog_generation = state.watchdog_generation.wrapping_add(1); - state.session = QconnectSessionState::default(); - state.session_renderer_states.clear(); - } - if let Err(err) = runtime.app.disconnect().await { - let mut guard = self.inner.lock().await; - guard.last_error = Some(format!("qconnect disconnect failed: {err}")); - } - runtime.event_loop.abort(); - let _ = runtime.event_loop.await; - } + pub async fn disconnect_safely(&self) -> Result { + self.disconnect_with_owner_policy(true).await + } - if let Ok(mut s) = self.shared.lock() { - s.qconnect.state = "off".to_string(); - s.qconnect.session_active = false; - s.emit_qconnect_session_changed(); - } - Ok(()) + /// Credential replacement/logout tears delegated material down before the + /// owner session mutates. The saved owner queue is restored locally (no + /// owner cloud runtime is rebuilt), so a guest queue can never reach the + /// next account or the final session save. + pub async fn disconnect_for_credentials_change(&self) -> Result<(), String> { + self.disconnect_safely().await.map(|_| ()) } /// T11 (`POST /api/settings/reload`): re-cache the device-name override from @@ -340,32 +1030,67 @@ impl DaemonQconnectService { /// `connect()` with the bounded [2s, 5s, 15s, 30s] retry schedule. Each /// `connect()` re-resolves the transport config internally, so a transient /// credential/network failure can clear on a later attempt. - async fn connect_on_ready(self: Arc) { + async fn connect_on_ready(self: Arc, enable_token: QconnectEnableToken) { loop { + if !self.enable_intent.is_current(enable_token) { + log::info!("[QConnect] auto-connect cancelled before Ready"); + return; + } let logged_in = self .shared .lock() .map(|s| s.auth == AuthState::LoggedIn) .unwrap_or(false); - if logged_in && self.runtime.core().is_api_initialized().await { + let api_initialized = logged_in && self.runtime.core().is_api_initialized().await; + if !self.enable_intent.is_current(enable_token) { + return; + } + if api_initialized { break; } + if !self.enable_intent.is_current(enable_token) { + return; + } tokio::time::sleep(Duration::from_millis(500)).await; + if !self.enable_intent.is_current(enable_token) { + return; + } } let schedule: [u64; 4] = [2_000, 5_000, 15_000, 30_000]; for attempt in 0..=schedule.len() { - match self.connect().await { + if !self.enable_intent.is_current(enable_token) { + log::info!("[QConnect] auto-connect cancelled by disable"); + return; + } + match self.connect_with_token(enable_token).await { Ok(()) => { + if !self.enable_intent.is_current(enable_token) { + return; + } log::info!("[QConnect] auto-connect succeeded"); return; } Err(err) => { - log::warn!("[QConnect] auto-connect attempt {} failed: {err}", attempt + 1); + if !self.enable_intent.is_current(enable_token) { + return; + } + log::warn!( + "[QConnect] auto-connect attempt {} failed: {err}", + attempt + 1 + ); } } match schedule.get(attempt) { - Some(delay_ms) => tokio::time::sleep(Duration::from_millis(*delay_ms)).await, + Some(delay_ms) => { + if !self.enable_intent.is_current(enable_token) { + return; + } + tokio::time::sleep(Duration::from_millis(*delay_ms)).await; + if !self.enable_intent.is_current(enable_token) { + return; + } + } None => { log::warn!( "[QConnect] auto-connect gave up for this session after {} attempts", @@ -390,6 +1115,8 @@ pub struct QconnectHandle { /// The queue-publish subscriber (publish.rs). Same #521 ordering contract as /// `report_task` — it clones `Arc` + the qconnect inner. publish_task: Option>, + /// Mirrors the coordinator's sanitized phase/generation into daemon status. + delegation_task: Option>, } /// T11: a `Clone`-able, `Send + Sync` handle onto the running @@ -411,6 +1138,22 @@ impl QconnectControl { self.0.disconnect().await } + pub async fn disconnect_for_credentials_change(&self) -> Result<(), String> { + self.0.disconnect_for_credentials_change().await + } + + pub fn owner_actions_allowed(&self) -> bool { + self.0.authority.owner_actions_allowed() + } + + pub fn try_owner_action_permit(&self) -> Option { + self.0.authority.try_owner_action_permit() + } + + pub fn try_transport_action_permit(&self) -> Option { + self.0.authority.try_transport_action_permit() + } + /// Re-cache the device-name override from the daemon-root KV (§ see /// [`DaemonQconnectService::refresh_device_name`] — applies on the NEXT /// connect, never forces a reconnect for a rename alone). @@ -451,6 +1194,13 @@ impl QconnectHandle { watcher.abort(); let _ = watcher.await; } + // Normative shutdown order: withdraw LAN admission and cancel the + // coordinator before stopping report/publish tasks or playback. + let _ = self.service.disconnect().await; + if let Some(delegation_task) = self.delegation_task.take() { + delegation_task.abort(); + let _ = delegation_task.await; + } // T10: stop the report scheduler too, so its `Arc` clone drops // ahead of `drop(booted)`. if let Some(report_task) = self.report_task.take() { @@ -462,7 +1212,64 @@ impl QconnectHandle { publish_task.abort(); let _ = publish_task.await; } - let _ = self.service.disconnect().await; + } +} + +fn delegation_error_label(error: DelegationErrorCode) -> &'static str { + match error { + DelegationErrorCode::CandidateExpired => "candidate-expired", + DelegationErrorCode::CandidateCancelled => "candidate-cancelled", + DelegationErrorCode::ValidationTimedOut => "validation-timeout", + DelegationErrorCode::ApiRejected => "api-rejected", + DelegationErrorCode::QwsRejected => "qws-rejected", + DelegationErrorCode::ActivationRejected => "activation-rejected", + DelegationErrorCode::CommitRejected => "commit-rejected", + DelegationErrorCode::OwnerRestoreFailed => "owner-restore-failed", + DelegationErrorCode::GenerationExhausted => "generation-exhausted", + DelegationErrorCode::Internal => "internal", + } +} + +fn latch_delegation_snapshot(shared: &SharedState, snapshot: DelegationSnapshot) { + if let Ok(mut state) = shared.lock() { + state.qconnect.credential_origin = match snapshot.credential_origin { + CredentialOrigin::Owner => "owner", + CredentialOrigin::Delegated { .. } => "delegated", + } + .to_string(); + state.qconnect.candidate_generation = snapshot.candidate_generation; + match snapshot.phase { + DelegationPhase::Disabled | DelegationPhase::ShuttingDown => { + state.qconnect.lan_state = "off".to_string(); + state.qconnect.lan_port = None; + } + DelegationPhase::CandidateValidating { .. } + | DelegationPhase::CandidateValidated { .. } + | DelegationPhase::Activating { .. } => { + state.qconnect.lan_state = "validating".to_string(); + state.qconnect.last_lan_error = None; + } + DelegationPhase::DelegatedActive { .. } => { + state.qconnect.lan_state = "delegated".to_string(); + state.qconnect.last_lan_error = None; + } + DelegationPhase::RestoringOwner { .. } => { + state.qconnect.lan_state = "restoring".to_string(); + state.qconnect.last_lan_error = None; + } + DelegationPhase::OwnerReady => { + if state.qconnect.lan_port.is_some() { + state.qconnect.lan_state = "listening".to_string(); + if snapshot.last_error.is_none() { + state.qconnect.last_lan_error = None; + } + } + } + } + if let Some(error) = snapshot.last_error { + state.qconnect.last_lan_error = Some(delegation_error_label(error).to_string()); + } + state.emit_qconnect_session_changed(); } } @@ -473,6 +1280,7 @@ impl QconnectHandle { pub fn start( runtime: Runtime, shared: SharedState, + authority: Arc, roots: &ProfileRoots, quality_cap: Arc>, report_notify: Arc, @@ -499,13 +1307,42 @@ pub fn start( s.qconnect.session_active = false; } + let inner = Arc::new(StdMutex::new(DaemonQconnectInner::default())); + let enable_intent = Arc::new(QconnectEnableIntent::new(should_auto_connect)); + let custom_device_name = Arc::new(tokio::sync::RwLock::new(custom_name)); + let delegation_host = Arc::new(DaemonDelegationHost::new( + Arc::clone(&runtime), + Arc::clone(&inner), + Arc::clone(&shared), + settings_db.clone(), + Arc::clone(&custom_device_name), + Arc::clone(&quality_cap), + Arc::clone(&authority), + )); + let coordinator = DelegationCoordinator::disabled( + Arc::clone(&delegation_host), + DelegationCoordinatorConfig::default(), + ); + assert!( + delegation_host.install_coordinator(coordinator.clone()), + "qconnect delegation coordinator may only be installed once" + ); let service = Arc::new(DaemonQconnectService { - inner: Arc::new(Mutex::new(DaemonQconnectInner::default())), + inner, + authority, + enable_intent, runtime, shared, settings_db, - custom_device_name: Arc::new(tokio::sync::RwLock::new(custom_name)), + custom_device_name, quality_cap, + delegation_host, + coordinator, + lan: AsyncMutex::new(None), + lan_start_pending: Arc::new(AtomicBool::new(false)), + lan_teardown_unsafe: Arc::new(AtomicBool::new(false)), + lifecycle_gate: AsyncMutex::new(()), + teardown_incomplete: AtomicBool::new(false), }); let watcher = if should_auto_connect { @@ -514,7 +1351,13 @@ pub fn start( mode.as_str() ); let svc = Arc::clone(&service); - Some(tokio::spawn(async move { svc.connect_on_ready().await })) + let enable_token = service + .enable_intent + .current_token() + .expect("enabled startup intent must have a token"); + Some(tokio::spawn(async move { + svc.connect_on_ready(enable_token).await + })) } else { log::info!( "[QConnect] auto-connect disabled (startup mode = {})", @@ -529,8 +1372,15 @@ pub fn start( // connect installs a runtime). let scheduler_inner = Arc::clone(&service.inner); let scheduler_runtime = Arc::clone(&service.runtime); + let scheduler_authority = Arc::clone(&service.authority); let report_task = Some(tokio::spawn(async move { - report::run_report_scheduler(report_notify, scheduler_inner, scheduler_runtime).await; + report::run_report_scheduler( + report_notify, + scheduler_inner, + scheduler_runtime, + scheduler_authority, + ) + .await; })); // Queue-publish subscriber (publish.rs): debounced CoreEvent::QueueUpdated -> @@ -540,13 +1390,27 @@ pub fn start( let publish_task = Some(publish::spawn_queue_cloud_publish( Arc::clone(&service.inner), Arc::clone(&service.runtime), + Arc::clone(&service.authority), core_events, )); + let mut delegation_rx = service.coordinator.subscribe(); + let delegation_shared = Arc::clone(&service.shared); + let delegation_task = Some(tokio::spawn(async move { + loop { + let snapshot = *delegation_rx.borrow_and_update(); + latch_delegation_snapshot(&delegation_shared, snapshot); + if delegation_rx.changed().await.is_err() { + break; + } + } + })); + QconnectHandle { service, watcher, report_task, publish_task, + delegation_task, } } diff --git a/crates/qbzd/src/qconnect/publish.rs b/crates/qbzd/src/qconnect/publish.rs index 6a8cf2a06..8dd474b71 100644 --- a/crates/qbzd/src/qconnect/publish.rs +++ b/crates/qbzd/src/qconnect/publish.rs @@ -23,18 +23,19 @@ //! (same pattern as `daemon.rs::spawn_queue_persist`), which ALSO covers queue //! edits while paused/stopped — a transition-only hook would miss those. -use std::sync::Arc; +use std::sync::{Arc, Mutex as StdMutex}; use qbz_app::shell::AppRuntime; use qbz_models::CoreEvent; use qconnect_app::{is_local_renderer_active, QueueCommandType}; use serde_json::json; -use tokio::sync::{broadcast, Mutex}; +use tokio::sync::broadcast; use tokio::task::JoinHandle; use uuid::Uuid; -use crate::adapter::DaemonAdapter; +use super::authority::{AuthorityCell, AuthorityStamp}; use super::DaemonQconnectInner; +use crate::adapter::DaemonAdapter; /// Push the local core queue to the Connect session when it differs from the /// cloud's. No-op under any of the gates listed in the module docs. Echo-safe @@ -42,21 +43,34 @@ use super::DaemonQconnectInner; /// to the very queue it materialized locally, so a controller-pushed queue /// compares equal and is never bounced back. pub async fn publish_local_queue_if_changed( - inner: &Arc>, + inner: &Arc>, runtime: &Arc>, + authority: &AuthorityCell, + expected_stamp: AuthorityStamp, ) { - let (app, sync_state) = { - let guard = inner.lock().await; + let (app, sync_state, stamp) = { + let guard = inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); match guard.runtime.as_ref() { - Some(rt) => (Arc::clone(&rt.app), Arc::clone(&rt.sync_state)), + Some(rt) if rt.stamp == expected_stamp => { + (Arc::clone(&rt.app), Arc::clone(&rt.sync_state), rt.stamp) + } None => return, + Some(_) => return, } }; + if !authority.is_current(stamp) { + return; + } // Only push while WE are the active renderer (the user is driving the // daemon). When a peer owns playback, the peer publishes its own queue. { let state = sync_state.lock().await; + if !authority.is_current(stamp) { + return; + } if !is_local_renderer_active(&state.session) { return; } @@ -71,6 +85,9 @@ pub async fn publish_local_queue_if_changed( } let (tracks, current_index) = runtime.core().get_all_queue_tracks().await; + if !authority.is_current(stamp) { + return; + } if tracks.is_empty() { return; } @@ -80,17 +97,31 @@ pub async fn publish_local_queue_if_changed( // inbound) so our own adoption / a remote queue change never bounces back. { let state = sync_state.lock().await; + if !authority.is_current(stamp) { + return; + } if let Some(applied) = &state.last_applied_queue_state { - let applied_ids: Vec = - applied.queue_items.iter().map(|item| item.track_id).collect(); + let applied_ids: Vec = applied + .queue_items + .iter() + .map(|item| item.track_id) + .collect(); if applied_ids == ordered_ids { return; } } } // ...and skip when we already pushed this exact queue (cloud echo pending). + if !authority.is_current(stamp) { + return; + } { - let guard = inner.lock().await; + let guard = inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !installed_runtime_matches(&guard, stamp) { + return; + } if guard.last_pushed_queue_ids.as_deref() == Some(ordered_ids.as_slice()) { return; } @@ -106,9 +137,20 @@ pub async fn publish_local_queue_if_changed( source != "local" && source != "plex" && track.id > 0 }); if !all_eligible { + if !authority.is_current(stamp) { + return; + } log::info!("[QConnect] Local queue has non-Qobuz tracks; not casting to Connect"); // Remember it so we don't re-log on every queue event within this queue. - let mut guard = inner.lock().await; + if !authority.is_current(stamp) { + return; + } + let mut guard = inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !installed_runtime_matches(&guard, stamp) { + return; + } guard.last_pushed_queue_ids = Some(ordered_ids); return; } @@ -128,18 +170,52 @@ pub async fn publish_local_queue_if_changed( let command = app .build_queue_command(QueueCommandType::CtrlSrvrQueueLoadTracks, payload) .await; + if !authority.is_current(stamp) { + return; + } match app.send_queue_command(command).await { Ok(_) => { + if !authority.is_current(stamp) { + return; + } log::info!( "[QConnect] Pushed local queue to Connect ({count} tracks, start={start_index})" ); - let mut guard = inner.lock().await; + if !authority.is_current(stamp) { + return; + } + let mut guard = inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !installed_runtime_matches(&guard, stamp) { + return; + } guard.last_pushed_queue_ids = Some(ordered_ids); } - Err(err) => log::warn!("[QConnect] Failed to push local queue: {err}"), + Err(err) if authority.is_current(stamp) => { + log::warn!("[QConnect] Failed to push local queue: {err}") + } + Err(_) => {} } } +fn installed_runtime_matches(inner: &DaemonQconnectInner, stamp: AuthorityStamp) -> bool { + inner.runtime.as_ref().map(|runtime| runtime.stamp) == Some(stamp) +} + +fn capture_installed_stamp( + inner: &StdMutex, + authority: &AuthorityCell, +) -> Option { + let stamp = inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .runtime + .as_ref() + .map(|runtime| runtime.stamp)?; + authority.is_current(stamp).then_some(stamp) +} + /// The queue-publish subscriber: debounces `CoreEvent::QueueUpdated` bursts by /// 2 s (same ritual as `daemon.rs::spawn_queue_persist`), then runs /// [`publish_local_queue_if_changed`]. Non-queue events are drained WITHOUT @@ -148,8 +224,9 @@ pub async fn publish_local_queue_if_changed( /// aborted+joined in `QconnectHandle::shutdown` ahead of `drop(booted)` (the /// #521 ordering), exactly like the report scheduler. pub fn spawn_queue_cloud_publish( - inner: Arc>, + inner: Arc>, runtime: Arc>, + authority: Arc, mut rx: broadcast::Receiver, ) -> JoinHandle<()> { use tokio::sync::broadcast::error::RecvError; @@ -157,12 +234,17 @@ pub fn spawn_queue_cloud_publish( tokio::spawn(async move { loop { // Block until the FIRST queue mutation of a burst. - match rx.recv().await { - Ok(CoreEvent::QueueUpdated { .. }) => {} + let mut event_stamp = match rx.recv().await { + Ok(CoreEvent::QueueUpdated { .. }) => { + let Some(stamp) = capture_installed_stamp(&inner, &authority) else { + continue; + }; + stamp + } Ok(_) => continue, Err(RecvError::Lagged(_)) => continue, Err(RecvError::Closed) => return, - } + }; // Debounce: a fixed deadline that only a further QueueUpdated extends. let mut deadline = tokio::time::Instant::now() + DEBOUNCE; loop { @@ -170,6 +252,10 @@ pub fn spawn_queue_cloud_publish( _ = tokio::time::sleep_until(deadline) => break, r = rx.recv() => match r { Ok(CoreEvent::QueueUpdated { .. }) => { + let Some(stamp) = capture_installed_stamp(&inner, &authority) else { + break; + }; + event_stamp = stamp; deadline = tokio::time::Instant::now() + DEBOUNCE; } Ok(_) => {} @@ -178,7 +264,7 @@ pub fn spawn_queue_cloud_publish( } } } - publish_local_queue_if_changed(&inner, &runtime).await; + publish_local_queue_if_changed(&inner, &runtime, &authority, event_stamp).await; } }) } diff --git a/crates/qbzd/src/qconnect/remote_stream.rs b/crates/qbzd/src/qconnect/remote_stream.rs index e0571a344..38f93276a 100644 --- a/crates/qbzd/src/qconnect/remote_stream.rs +++ b/crates/qbzd/src/qconnect/remote_stream.rs @@ -23,6 +23,31 @@ use std::time::Duration; use qbz_player::{BufferWriter, Player}; +/// Owned background feeder for one progressive remote stream. +/// +/// Tokio normally detaches a task when its `JoinHandle` is dropped. This +/// wrapper deliberately aborts instead: the renderer engine owns exactly one +/// feeder, so replacing/stopping an authority cannot leave an old CDN request +/// writing into a retired buffer. +#[must_use = "dropping the feeder aborts the background CDN request"] +pub struct RemoteStreamFeeder { + task: Option>, +} + +impl RemoteStreamFeeder { + fn new(task: tokio::task::JoinHandle<()>) -> Self { + Self { task: Some(task) } + } +} + +impl Drop for RemoteStreamFeeder { + fn drop(&mut self) { + if let Some(task) = self.task.take() { + task.abort(); + } + } +} + /// Format/size facts sniffed from a remote audio URL before streaming. pub struct RemoteStreamInfo { pub content_length: u64, @@ -45,8 +70,10 @@ pub async fn stream_remote_track_into_player( start_position_secs: u64, url: &str, log_tag: &str, -) -> Result<(), String> { + authority_check: impl FnOnce() -> Result<(), String>, +) -> Result { let stream_info = probe_remote_stream_info(url).await?; + authority_check()?; log::info!( "[{}/STREAMING] Track {} - {:.2} MB, {}Hz, {} ch, {}-bit, {:.1} MB/s", log_tag, @@ -74,7 +101,7 @@ pub async fn stream_remote_track_into_player( let url = url.to_string(); let content_length = stream_info.content_length; let log_tag = log_tag.to_string(); - tokio::spawn(async move { + let task = tokio::spawn(async move { if let Err(err) = download_and_stream_remote_track(&url, writer, track_id, content_length, &log_tag).await { @@ -87,7 +114,7 @@ pub async fn stream_remote_track_into_player( } }); - Ok(()) + Ok(RemoteStreamFeeder::new(task)) } /// HEAD for content-length, then a small `Range: bytes=0-65535` GET to (a) @@ -101,14 +128,19 @@ pub async fn probe_remote_stream_info(url: &str) -> Result Result100-header small-object flood surfaces as hyper's -/// "message head is too large" two levels down). Walk `source()` and join the -/// chain so logs AND signature matching see the real cause. +/// Return a bounded, URL-free diagnostic for a reqwest failure. +/// +/// The raw error and its source chain can contain the signed CDN URL (including +/// its query). We inspect the chain only to preserve the known header-limit +/// classification and never copy an arbitrary cause into the returned string. pub fn describe_reqwest_error(err: &reqwest::Error) -> String { - use std::error::Error as _; - let mut out = err.to_string(); - let mut source = err.source(); - while let Some(cause) = source { - out.push_str(": "); - out.push_str(&cause.to_string()); - source = cause.source(); + if error_chain_has_header_limit(err) { + return safe_transport_diagnostic( + "message head is too large; signed URL and query are intentionally omitted", + ) + .to_string(); + } + + if err.is_timeout() { + "HTTP transport timed out".to_string() + } else if err.is_connect() { + "HTTP transport connection failed".to_string() + } else if err.is_body() { + "HTTP response body failed".to_string() + } else if err.is_decode() { + "HTTP response decode failed".to_string() + } else if err.is_status() { + "HTTP status rejected".to_string() + } else { + "HTTP transport request failed".to_string() } - out } -/// True when an error message (already chain-expanded by -/// [`describe_reqwest_error`]) shows hyper's hard-coded h1 100-header cap. +fn error_chain_has_header_limit(err: &reqwest::Error) -> bool { + use std::error::Error as _; + + if is_header_flood_error(&err.to_string()) { + return true; + } + let mut source = err.source(); + while let Some(cause) = source { + if is_header_flood_error(&cause.to_string()) { + return true; + } + source = cause.source(); + } + false +} + +/// Map arbitrary transport text to one of a few constant diagnostics. This is +/// intentionally lossy: CDN URLs and their credential-bearing query strings +/// are never valid log or API output. +fn safe_transport_diagnostic(message: &str) -> &'static str { + if is_header_flood_error(message) { + "HTTP response header limit exceeded (message head is too large)" + } else { + "HTTP transport request failed" + } +} + +/// True when a sanitized diagnostic (or an internal raw cause inspected before +/// logging) shows hyper's hard-coded h1 100-header cap. /// Akamai answers SMALL raw-url objects with ~106 headers (the `X-AK-GRN` / /// `X-AK-FWD-ERROR: ERR_POC_FWD_OBJ_TOO_SMALL` flood), so EVERY reqwest fetch /// of such an URL fails this way — streaming probe and full download alike. @@ -319,3 +399,26 @@ pub fn is_header_flood_error(message: &str) -> bool { let haystack = message.to_ascii_lowercase(); haystack.contains("message head is too large") || haystack.contains("too many headers") } + +#[cfg(test)] +mod tests { + use super::{is_header_flood_error, safe_transport_diagnostic}; + + #[test] + fn cdn_diagnostic_never_echoes_url_or_query() { + let raw = "request failed for https://cdn.example/audio.flac?jwt=super-secret"; + let diagnostic = safe_transport_diagnostic(raw); + assert_eq!(diagnostic, "HTTP transport request failed"); + assert!(!diagnostic.contains("cdn.example")); + assert!(!diagnostic.contains("super-secret")); + } + + #[test] + fn sanitized_header_limit_remains_machine_classifiable() { + let raw = "https://cdn.example/audio?token=secret: message head is too large"; + let diagnostic = safe_transport_diagnostic(raw); + assert!(is_header_flood_error(diagnostic)); + assert!(!diagnostic.contains("cdn.example")); + assert!(!diagnostic.contains("secret")); + } +} diff --git a/crates/qbzd/src/qconnect/report.rs b/crates/qbzd/src/qconnect/report.rs index e1459d8f8..ad708a544 100644 --- a/crates/qbzd/src/qconnect/report.rs +++ b/crates/qbzd/src/qconnect/report.rs @@ -14,7 +14,7 @@ //! `duration_ms` are MILLISECONDS (the QConnect protocol unit). #![allow(dead_code)] -use std::sync::Arc; +use std::sync::{Arc, Mutex as StdMutex}; use qbz_app::shell::AppRuntime; use qbz_player::player::PlaybackBufferState; @@ -25,8 +25,9 @@ use qconnect_app::{ use tokio::sync::Mutex; use uuid::Uuid; -use crate::adapter::DaemonAdapter; +use super::authority::{AuthorityCell, AuthorityStamp}; use super::sink::DaemonQconnectApp; +use crate::adapter::DaemonAdapter; pub const QCONNECT_RENDERER_CHANNELS: i32 = 2; const AUDIO_QUALITY_UNKNOWN: i32 = 0; @@ -45,25 +46,39 @@ pub async fn report_playback_state( app: &Arc, sync_state: &Arc>, runtime: &Arc>, + authority: &AuthorityCell, + stamp: AuthorityStamp, playing_state: i32, position_ms: i64, duration_ms: i64, track_id: u64, buffer_state: PlaybackBufferState, ) { + if !authority.is_current(stamp) { + return; + } // Only report when WE are the active renderer. When a peer renderer owns // playback (the daemon is acting as a controller) the renderer reports come // from the peer, not us. { let state = sync_state.lock().await; + if !authority.is_current(stamp) { + return; + } if !is_local_renderer_active(&state.session) { return; } } let (current_qid, next_qid) = - resolve_queue_item_ids_by_track_id(app, sync_state, track_id).await; + resolve_queue_item_ids_by_track_id(app, sync_state, authority, stamp, track_id).await; + if !authority.is_current(stamp) { + return; + } let queue_version = app.queue_state_snapshot().await.version; + if !authority.is_current(stamp) { + return; + } let report = build_renderer_playback_report( Uuid::new_v4().to_string(), @@ -77,13 +92,22 @@ pub async fn report_playback_state( next_queue_item_id: next_qid, }, ); + if !authority.is_current(stamp) { + return; + } if let Err(err) = app.send_renderer_report_command(report).await { + if !authority.is_current(stamp) { + return; + } log::warn!("[QConnect] Failed to report playback state: {err}"); } - if position_ms >= 0 { + if position_ms >= 0 && authority.is_current(stamp) { app.update_renderer_position(position_ms as u64).await; } + if !authority.is_current(stamp) { + return; + } // Report the live output format so the controller shows the correct quality // badge (CD / Hi-Res). Reads the player's current output (sample_rate/ @@ -95,12 +119,21 @@ pub async fn report_playback_state( if let Some(snapshot) = build_file_audio_quality_snapshot(sample_rate, bit_depth, QCONNECT_RENDERER_CHANNELS) { + if !authority.is_current(stamp) { + return; + } if let Err(err) = app .report_file_audio_quality_if_changed(queue_version, snapshot) .await { + if !authority.is_current(stamp) { + return; + } log::warn!("[QConnect] Failed to report file audio quality: {err}"); } + if !authority.is_current(stamp) { + return; + } if let Err(err) = app .report_device_audio_quality_if_changed( queue_version, @@ -110,6 +143,9 @@ pub async fn report_playback_state( ) .await { + if !authority.is_current(stamp) { + return; + } log::warn!("[QConnect] Failed to report device audio quality: {err}"); } } @@ -158,14 +194,25 @@ fn build_file_audio_quality_snapshot( async fn resolve_queue_item_ids_by_track_id( app: &Arc, sync_state: &Arc>, + authority: &AuthorityCell, + stamp: AuthorityStamp, track_id: u64, ) -> (Option, Option) { + if !authority.is_current(stamp) { + return (None, None); + } let queue = app.queue_state_snapshot().await; + if !authority.is_current(stamp) { + return (None, None); + } let (current_qid, next_qid, next_track_id) = qconnect_app::queue_resolution::resolve_queue_item_ids_from_queue_state(&queue, track_id); if let Some(current_qid) = current_qid { let mut state = sync_state.lock().await; + if !authority.is_current(stamp) { + return (None, None); + } state.last_renderer_queue_item_id = Some(current_qid); state.last_renderer_next_queue_item_id = next_qid; state.last_renderer_track_id = Some(track_id); @@ -195,8 +242,9 @@ async fn resolve_queue_item_ids_by_track_id( // `is_playing`, so a paused/stopped renderer stays silent like the desktop. pub async fn run_report_scheduler( notify: Arc, - inner: Arc>, + inner: Arc>, runtime: Arc>, + authority: Arc, ) { use qconnect_app::renderer::{PLAYING_STATE_PAUSED, PLAYING_STATE_PLAYING}; @@ -211,8 +259,27 @@ pub async fn run_report_scheduler( // Reset so the periodic floor only fires after 2 s of edge silence. interval.reset(); + // Capture one exact installed runtime before reading player state. If a + // handoff lands while the snapshot is read, the stamp check below drops + // the mixed-authority sample. + let (app, sync_state, stamp) = { + let guard = inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + match guard.runtime.as_ref() { + Some(rt) => (Arc::clone(&rt.app), Arc::clone(&rt.sync_state), rt.stamp), + _ => continue, + } + }; + if !authority.is_current(stamp) { + continue; + } + // Read the live player state. Nothing loaded -> nothing to report. let ev = runtime.core().player().get_playback_event(); + if !authority.is_current(stamp) { + continue; + } let report_track_id = qconnect_report_track_id(&ev); if report_track_id == 0 { continue; @@ -229,16 +296,6 @@ pub async fn run_report_scheduler( continue; } - // Resolve the LIVE session (app + the shared sync accumulator). No runtime - // means QConnect is not connected -> a no-op this tick. - let (app, sync_state) = { - let guard = inner.lock().await; - match guard.runtime.as_ref() { - Some(rt) => (Arc::clone(&rt.app), Arc::clone(&rt.sync_state)), - None => continue, - } - }; - let playing_state = if ev.is_playing { PLAYING_STATE_PLAYING } else { @@ -251,6 +308,8 @@ pub async fn run_report_scheduler( &app, &sync_state, &runtime, + &authority, + stamp, playing_state, position_ms, duration_ms, diff --git a/crates/qbzd/src/qconnect/session.rs b/crates/qbzd/src/qconnect/session.rs index 79ecc540b..3dcc48bf0 100644 --- a/crates/qbzd/src/qconnect/session.rs +++ b/crates/qbzd/src/qconnect/session.rs @@ -20,7 +20,7 @@ //! //! The desktop's D5 offline-mode parks are dropped: qbzd has no offline MODE. -use std::sync::Arc; +use std::sync::{Arc, Mutex as StdMutex}; use qbz_app::shell::AppRuntime; use qconnect_app::renderer::PLAYING_STATE_STOPPED; @@ -32,15 +32,17 @@ use serde_json::json; use tokio::sync::Mutex; use uuid::Uuid; -use crate::adapter::DaemonAdapter; -use crate::state::DaemonShared; +use super::authority::{AuthorityCell, AuthorityStamp}; use super::engine::VolumeMode; // T10 (OD4): join-time volume report honors the mode +use super::lan::DaemonLanProjectionSlot; use super::sink::{DaemonEventSink, DaemonQconnectApp}; use super::transport::{ default_qconnect_device_info, default_qconnect_device_info_with_name, resolve_transport_config, QconnectJoinSessionRequest, AUDIO_QUALITY_HIRES_LEVEL2, }; use super::{update_lifecycle_state_if_running, DaemonQconnectInner}; +use crate::adapter::DaemonAdapter; +use crate::state::DaemonShared; type Runtime = Arc>; @@ -52,22 +54,39 @@ type Runtime = Arc>; pub struct DaemonSessionLoopHost { pub app: Arc, pub sync_state: Arc>, - pub inner: Arc>, + pub inner: Arc>, + pub authority: Arc, + pub stamp: AuthorityStamp, pub sink: Arc, pub runtime: Runtime, pub shared: Arc>, /// T10 (OD4): resolved volume policy — the deferred renderer join reports 100 /// in `Locked` mode, the real player volume in `Software`. pub volume_mode: VolumeMode, + pub(crate) projection: DaemonLanProjectionSlot, } #[async_trait::async_trait] impl SessionLoopHost for DaemonSessionLoopHost { async fn update_lifecycle(&self, state: QconnectLifecycleState) { - update_lifecycle_state_if_running(&self.inner, &self.sink, &self.shared, state).await; + if !self.authority.is_current(self.stamp) { + return; + } + update_lifecycle_state_if_running( + &self.inner, + &self.sink, + &self.shared, + &self.authority, + self.stamp, + state, + ) + .await; } async fn bootstrap_after_reconnect(&self) { + if !self.authority.is_current(self.stamp) { + return; + } // FIX (b) (IV3, daemon-copy only): the WS transport reuses `last_config` // on in-loop reconnects and never re-resolves `/qws/createToken`, so a // reconnect after the JWT expires keeps failing. Re-resolve the transport @@ -76,17 +95,46 @@ impl SessionLoopHost for DaemonSessionLoopHost { // desktop copy does NOT do this. match resolve_transport_config(&self.runtime).await { Ok(fresh) => { - let mut guard = self.inner.lock().await; - if let Some(rt) = guard.runtime.as_mut() { - rt.config = fresh; + if !self.authority.is_current(self.stamp) { + return; + } + let latched = { + let mut guard = self + .inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(rt) = guard.runtime.as_mut() { + if rt.stamp == self.stamp { + rt.config = fresh; + true + } else { + false + } + } else { + false + } + }; + if !latched || !self.authority.is_current(self.stamp) { + return; } log::info!("[QConnect] reconnect: re-resolved transport credentials (fresh JWT)"); } Err(err) => { + if !self.authority.is_current(self.stamp) { + return; + } log::warn!("[QConnect] reconnect: credential re-resolve failed: {err}"); } } - if let Err(err) = bootstrap_remote_presence(&self.app, None).await { + if !self.authority.is_current(self.stamp) { + return; + } + if let Err(err) = + bootstrap_remote_presence(&self.app, None, &self.authority, self.stamp).await + { + if !self.authority.is_current(self.stamp) { + return; + } log::error!("[QConnect] Re-bootstrap after reconnect failed: {err}"); } } @@ -99,6 +147,8 @@ impl SessionLoopHost for DaemonSessionLoopHost { self.volume_mode, // T10 (OD4): 100 in Locked, real in Software &session_uuid, reason, + &self.authority, + self.stamp, ) .await; } @@ -109,20 +159,40 @@ impl SessionLoopHost for DaemonSessionLoopHost { last_reason: String, idle_retry_active: bool, ) -> bool { - { - let mut guard = self.inner.lock().await; - guard.lifecycle_state = QconnectLifecycleState::Exhausted; - guard.last_error = Some(format!( - "Reconnect attempts exhausted ({attempts}): {last_reason}" - )); - if !idle_retry_active { - // Legacy terminate path: drop the runtime so a fresh connect() - // succeeds. Dropping it detaches this task's own JoinHandle (fine - // — the loop breaks right after). - guard.runtime = None; + if !self.authority.is_current(self.stamp) { + return true; + } + let (applied, retired) = { + let mut guard = self + .inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if guard.runtime.as_ref().map(|rt| rt.stamp) != Some(self.stamp) { + (false, None) + } else { + guard.lifecycle_state = QconnectLifecycleState::Exhausted; + guard.last_error = Some(format!( + "Reconnect attempts exhausted ({attempts}): {last_reason}" + )); + if !idle_retry_active { + // Legacy terminate path: drop the runtime so a fresh connect() + // succeeds. Dropping it detaches this task's own JoinHandle + // (fine — the loop breaks right after). + (true, guard.runtime.take()) + } else { + (true, None) + } } + }; + // Never drop a retired runtime while holding the synchronous inner lock. + drop(retired); + if !applied || !self.authority.is_current(self.stamp) { + return true; } if let Ok(mut s) = self.shared.lock() { + if !self.authority.is_current(self.stamp) { + return true; + } s.qconnect.state = "exhausted".to_string(); s.qconnect.session_active = false; // 01 §9.3: reconnect-exhausted is a real network-class failure — @@ -132,6 +202,12 @@ impl SessionLoopHost for DaemonSessionLoopHost { s.emit_qconnect_session_changed(); } log::warn!("[QConnect] Reconnect exhausted ({attempts}): {last_reason}"); + if !idle_retry_active { + // Invalidate this sink/host after publishing the terminal status. + // `clear_if_current` cannot clear a replacement installed in between. + self.projection + .clear_if_current(&self.authority, self.stamp); + } // Daemon has no offline MODE, so the desktop's idle-retry offline park is // dropped: keep idling (the transport's internal 60s idle rearm keeps // trying) unless idle-retry is off, in which case break + drop. @@ -139,6 +215,9 @@ impl SessionLoopHost for DaemonSessionLoopHost { } async fn on_loop_error(&self, message: String) { + if !self.authority.is_current(self.stamp) { + return; + } // Slint copy surfaced a toast here — daemon logs it. log::error!("[QConnect] session loop error: {message}"); } @@ -151,7 +230,34 @@ impl SessionLoopHost for DaemonSessionLoopHost { pub async fn bootstrap_remote_presence( app: &Arc, custom_device_name: Option, + authority: &AuthorityCell, + stamp: AuthorityStamp, ) -> Result<(), String> { + bootstrap_remote_presence_with_gate(app, custom_device_name, || authority.is_current(stamp)) + .await +} + +/// Complete the owner bootstrap on an isolated, authenticated/subscribed QWS +/// runtime before its authority commit. The receiver is already subscribed, so +/// resulting session events remain buffered for the loop installed at commit. +pub(crate) async fn bootstrap_prepared_owner_presence( + app: &Arc, + custom_device_name: Option, +) -> Result<(), String> { + bootstrap_remote_presence_with_gate(app, custom_device_name, || true).await +} + +async fn bootstrap_remote_presence_with_gate( + app: &Arc, + custom_device_name: Option, + is_current: F, +) -> Result<(), String> +where + F: Fn() -> bool, +{ + if !is_current() { + return Ok(()); + } let device_info = default_qconnect_device_info_with_name(custom_device_name.as_deref()); let join_payload = serde_json::to_value(QconnectJoinSessionRequest { @@ -163,22 +269,40 @@ pub async fn bootstrap_remote_presence( let join_command = app .build_queue_command(QueueCommandType::CtrlSrvrJoinSession, join_payload) .await; - let join_action_uuid = app - .send_queue_command(join_command) - .await - .map_err(|err| format!("send bootstrap ctrl_srvr_join_session failed: {err}"))?; + if !is_current() { + return Ok(()); + } + let join_action_uuid = match app.send_queue_command(join_command).await { + Ok(action_uuid) => action_uuid, + Err(_) if !is_current() => return Ok(()), + Err(err) => { + return Err(format!( + "send bootstrap ctrl_srvr_join_session failed: {err}" + )) + } + }; // JoinSession responds with session/renderer controller events not part of // queue reducer correlation. Drop the pending slot so queue ops aren't blocked. app.clear_pending_if_matches(&join_action_uuid).await; + if !is_current() { + return Ok(()); + } let ask_queue_command = app .build_queue_command(QueueCommandType::CtrlSrvrAskForQueueState, json!({})) .await; - let ask_action_uuid = app - .send_queue_command(ask_queue_command) - .await - .map_err(|err| format!("send bootstrap ask_for_queue_state failed: {err}"))?; + if !is_current() { + return Ok(()); + } + let ask_action_uuid = match app.send_queue_command(ask_queue_command).await { + Ok(action_uuid) => action_uuid, + Err(_) if !is_current() => return Ok(()), + Err(err) => return Err(format!("send bootstrap ask_for_queue_state failed: {err}")), + }; app.clear_pending_if_matches(&ask_action_uuid).await; + if !is_current() { + return Ok(()); + } log::info!( "[QConnect] Bootstrap complete: controller joined, queue state requested. Renderer join deferred until session_uuid received." @@ -198,16 +322,28 @@ pub async fn deferred_renderer_join( volume_mode: VolumeMode, // T10 (OD4): join-time volume report policy session_uuid: &str, join_reason: i32, + authority: &AuthorityCell, + stamp: AuthorityStamp, ) { + if !authority.is_current(stamp) { + return; + } let already_joined = { let st = sync_state.lock().await; + if !authority.is_current(stamp) { + return; + } st.last_joined_session_uuid.as_deref() == Some(session_uuid) }; if already_joined { - log::info!( - "[QConnect] Deferred join skipped (already joined session_uuid={session_uuid}); re-asking renderer state" - ); + log::info!("[QConnect] Deferred join skipped (session already joined)"); + if !authority.is_current(stamp) { + return; + } if let Err(err) = app.ask_for_active_renderer_state().await { + if !authority.is_current(stamp) { + return; + } log::warn!("[QConnect] Idempotent-join AskForRendererState failed: {err}"); } return; @@ -215,8 +351,11 @@ pub async fn deferred_renderer_join( let device_info = default_qconnect_device_info(); let queue_version_ref = app.queue_state_snapshot().await.version; + if !authority.is_current(stamp) { + return; + } - log::info!("[QConnect] Deferred renderer join with session_uuid={session_uuid}"); + log::info!("[QConnect] Starting deferred renderer join"); // 1. Renderer JoinSession with session_uuid. // Do NOT auto-steal the render on a fresh connect: join as an AVAILABLE @@ -246,7 +385,13 @@ pub async fn deferred_renderer_join( queue_version_ref, renderer_join_payload, ); + if !authority.is_current(stamp) { + return; + } if let Err(err) = app.send_renderer_report_command(renderer_join_report).await { + if !authority.is_current(stamp) { + return; + } log::error!("[QConnect] Deferred renderer join failed: {err}"); return; } @@ -255,7 +400,13 @@ pub async fn deferred_renderer_join( // we may already have a current track, so resolve the real duration + current/ // next queue_item_ids instead of hardcoding nulls. let renderer = app.renderer_state_snapshot().await; + if !authority.is_current(stamp) { + return; + } let queue = app.queue_state_snapshot().await; + if !authority.is_current(stamp) { + return; + } let current_track_id = renderer.current_track.as_ref().map(|item| item.track_id); let (current_qid, next_qid, _) = current_track_id .map(|tid| { @@ -271,6 +422,9 @@ pub async fn deferred_renderer_join( .unwrap_or(0), None => 0, }; + if !authority.is_current(stamp) { + return; + } let mut state_report_payload = json!({ "playing_state": PLAYING_STATE_STOPPED, "buffer_state": RendererBufferState::Ok.as_i32(), @@ -293,7 +447,13 @@ pub async fn deferred_renderer_join( queue_version_ref, state_report_payload, ); + if !authority.is_current(stamp) { + return; + } if let Err(err) = app.send_renderer_report_command(state_report).await { + if !authority.is_current(stamp) { + return; + } log::error!("[QConnect] Deferred renderer state report failed: {err}"); } @@ -310,7 +470,13 @@ pub async fn deferred_renderer_join( queue_version_ref, json!({ "volume": volume_pct }), ); + if !authority.is_current(stamp) { + return; + } if let Err(err) = app.send_renderer_report_command(volume_report).await { + if !authority.is_current(stamp) { + return; + } log::error!("[QConnect] Deferred renderer volume report failed: {err}"); } @@ -320,7 +486,13 @@ pub async fn deferred_renderer_join( queue_version_ref, json!({ "max_audio_quality": AUDIO_QUALITY_HIRES_LEVEL2 }), ); + if !authority.is_current(stamp) { + return; + } if let Err(err) = app.send_renderer_report_command(max_quality_report).await { + if !authority.is_current(stamp) { + return; + } log::error!("[QConnect] Deferred renderer max quality report failed: {err}"); } @@ -329,17 +501,32 @@ pub async fn deferred_renderer_join( // Re-request session state so the server sends an updated renderer list // (including ourselves). tokio::time::sleep(std::time::Duration::from_millis(500)).await; + if !authority.is_current(stamp) { + return; + } let refresh_command = app .build_queue_command(QueueCommandType::CtrlSrvrAskForQueueState, json!({})) .await; + if !authority.is_current(stamp) { + return; + } if let Ok(action_uuid) = app.send_queue_command(refresh_command).await { app.clear_pending_if_matches(&action_uuid).await; + if !authority.is_current(stamp) { + return; + } log::info!("[QConnect] Re-requested session state after renderer join"); } // Resync the active renderer's full state too, so a reconnect rejoin restores // renderer state. + if !authority.is_current(stamp) { + return; + } if let Err(err) = app.ask_for_active_renderer_state().await { + if !authority.is_current(stamp) { + return; + } log::warn!("[QConnect] Post-join AskForRendererState failed: {err}"); } @@ -347,6 +534,9 @@ pub async fn deferred_renderer_join( // takes the idempotent fast-path above. { let mut st = sync_state.lock().await; + if !authority.is_current(stamp) { + return; + } st.last_joined_session_uuid = Some(session_uuid.to_string()); } } diff --git a/crates/qbzd/src/qconnect/sink.rs b/crates/qbzd/src/qconnect/sink.rs index 0131d282a..39079fc6e 100644 --- a/crates/qbzd/src/qconnect/sink.rs +++ b/crates/qbzd/src/qconnect/sink.rs @@ -30,7 +30,9 @@ use serde_json::Value; use tokio::sync::Mutex; use uuid::Uuid; +use super::authority::{AuthorityCell, AuthorityStamp}; use super::engine::DaemonRendererEngine; +use super::lan::DaemonLanProjectionSlot; use super::transport::resolve_local_identity; /// Concrete `QconnectApp` type used by the daemon adapter. @@ -42,6 +44,13 @@ pub struct DaemonEventSink { engine: DaemonRendererEngine, /// THE shared remote-sync accumulator (one Mutex, shared with `QconnectApp`). sync_state: Arc>, + /// Exact runtime authority. The sink is constructed while a runtime is + /// prepared, and remains dormant until this stamp is installed. + authority: Arc, + stamp: AuthorityStamp, + /// Public LAN session projection. Updates are serialized with authority + /// installs so late SESSION_STATE events cannot overwrite a replacement. + projection: DaemonLanProjectionSlot, /// Late-bound weak handle to the owning app, wired via `set_app` after the /// app is built FROM this sink. Used to emit renderer reports (e.g. /// is_active=true after SetActive(true)) and to drive the session-apply + @@ -61,10 +70,16 @@ impl DaemonEventSink { pub fn new( engine: DaemonRendererEngine, sync_state: Arc>, + authority: Arc, + stamp: AuthorityStamp, + projection: DaemonLanProjectionSlot, ) -> Self { Self { engine, sync_state, + authority, + stamp, + projection, app: Arc::new(OnceLock::new()), last_peer_active: std::sync::atomic::AtomicBool::new(false), } @@ -75,13 +90,23 @@ impl DaemonEventSink { let _ = self.app.set(Arc::downgrade(app)); } + fn is_current(&self) -> bool { + self.authority.is_current(self.stamp) + } + /// Emit a StateUpdated report announcing this renderer is now active. Sent /// after SetActive(true) is applied so the controller learns we are ready. async fn report_active_renderer_ready(&self) { + if !self.is_current() { + return; + } let Some(app) = self.app.get().and_then(Weak::upgrade) else { return; }; let queue_version = app.queue_state_snapshot().await.version; + if !self.is_current() { + return; + } let report = RendererReport::new( RendererReportType::RndrSrvrStateUpdated, Uuid::new_v4().to_string(), @@ -95,7 +120,13 @@ impl DaemonEventSink { } }), ); + if !self.is_current() { + return; + } if let Err(err) = app.send_renderer_report_command(report).await { + if !self.is_current() { + return; + } log::warn!("[QConnect] Failed to report active-renderer-ready: {err}"); } } @@ -106,6 +137,9 @@ impl DaemonEventSink { /// `apply_session_management_event`; the post-lock ordering (loop mode -> /// local-playback handoff -> projection -> freeze -> watchdog) is identical. async fn apply_session_management_event(&self, message_type: &str, payload: &Value) { + if !self.is_current() { + return; + } let Some(app) = self.app.get().and_then(Weak::upgrade) else { return; }; @@ -113,11 +147,34 @@ impl DaemonEventSink { let outcome = app .apply_session_management_event(message_type, payload, &identity) .await; + if !self.is_current() { + return; + } + + if message_type == "MESSAGE_TYPE_SRVR_CTRL_SESSION_STATE" { + let session_id = { + let state = self.sync_state.lock().await; + state.session.session_uuid.clone() + }; + if !self.is_current() { + return; + } + if let Some(session_id) = session_id { + self.projection + .confirm_owner_session(&self.authority, self.stamp, &session_id); + } + } if let Some(loop_mode) = outcome.apply_loop_mode { - if let Err(err) = qconnect_app::renderer::apply_remote_loop_mode(&self.engine, loop_mode) - .await + if !self.is_current() { + return; + } + if let Err(err) = + qconnect_app::renderer::apply_remote_loop_mode(&self.engine, loop_mode).await { + if !self.is_current() { + return; + } log::warn!("[QConnect] Failed to apply remote loop mode: {err}"); } } @@ -131,14 +188,23 @@ impl DaemonEventSink { } if let Some(renderer_id) = outcome.disconnected_renderer_id { + if !self.is_current() { + return; + } app.freeze_active_renderer_projection( renderer_id, QconnectAppEvent::RendererDisconnected { renderer_id }, ) .await; + if !self.is_current() { + return; + } } if let Some((renderer_id, generation)) = outcome.watchdog_arm { + if !self.is_current() { + return; + } app.arm_renderer_watchdog(renderer_id, generation); } @@ -150,13 +216,22 @@ impl DaemonEventSink { // align + projection resolve the real current track now. let peer_active_now = { let state = self.sync_state.lock().await; + if !self.is_current() { + return; + } is_peer_renderer_active(&state.session) }; let was_peer_active = self .last_peer_active .swap(peer_active_now, std::sync::atomic::Ordering::Relaxed); if peer_active_now && !was_peer_active { + if !self.is_current() { + return; + } if let Err(err) = app.ask_for_active_renderer_state().await { + if !self.is_current() { + return; + } log::warn!( "[QConnect] controller entry: ask_for_active_renderer_state failed: {err}" ); @@ -168,8 +243,14 @@ impl DaemonEventSink { /// the two don't double-play. Mirrors the Tauri helper, with the engine seam /// in place of `CoreBridge`. async fn sync_local_playback_for_renderer_ownership(&self) { + if !self.is_current() { + return; + } let peer_renderer_active = { let state = self.sync_state.lock().await; + if !self.is_current() { + return; + } is_peer_renderer_active(&state.session) }; if !peer_renderer_active { @@ -180,6 +261,9 @@ impl DaemonEventSink { if playback_state.track_id == 0 { return; } + if !self.is_current() { + return; + } log::info!( "[QConnect] Stopping local playback because active renderer is a peer (track_id={})", @@ -194,8 +278,14 @@ impl DaemonEventSink { /// playback, align the local queue cursor to the peer's current track (so a /// later takeover lands on the right track). Mirrors the Tauri helper. async fn sync_active_renderer_projection(&self, renderer_id: i32) { + if !self.is_current() { + return; + } let (queue_state, renderer_state, session_loop_mode, should_align_engine) = { let state = self.sync_state.lock().await; + if !self.is_current() { + return; + } let Some(active_renderer_id) = state.session.active_renderer_id else { return; }; @@ -220,8 +310,14 @@ impl DaemonEventSink { let renderer_snapshot = build_session_renderer_snapshot(&queue_state, Some(&renderer_state), session_loop_mode); + if !self.is_current() { + return; + } { let mut state = self.sync_state.lock().await; + if !self.is_current() { + return; + } cache_renderer_snapshot(&mut state, &renderer_snapshot); } @@ -232,28 +328,47 @@ impl DaemonEventSink { let Some(current_track) = renderer_snapshot.current_track.as_ref() else { return; }; + if !self.is_current() { + return; + } if let Err(err) = qconnect_app::renderer::align_queue_cursor(&self.engine, current_track.track_id).await { + if !self.is_current() { + return; + } log::warn!("[QConnect] Failed to sync peer renderer cursor into engine: {err}"); } } } +fn renderer_command_name(command: &RendererCommand) -> &'static str { + match command { + RendererCommand::SetState { .. } => "set_state", + RendererCommand::SetVolume { .. } => "set_volume", + RendererCommand::SetActive { .. } => "set_active", + RendererCommand::SetMaxAudioQuality { .. } => "set_max_audio_quality", + RendererCommand::SetLoopMode { .. } => "set_loop_mode", + RendererCommand::SetShuffleMode { .. } => "set_shuffle_mode", + RendererCommand::MuteVolume { .. } => "mute_volume", + } +} + #[async_trait] impl QconnectEventSink for DaemonEventSink { async fn on_event(&self, event: QconnectAppEvent) { + if !self.is_current() { + return; + } match &event { QconnectAppEvent::SessionManagementEvent { message_type, payload, } => { - log::info!( - "[QConnect] Session management: {} payload={}", - message_type, - serde_json::to_string(payload).unwrap_or_else(|_| "?".to_string()) - ); + // Payloads can contain session identifiers and controller + // topology. Log only the protocol message type. + log::info!("[QConnect] Session management: {message_type}"); self.apply_session_management_event(message_type, payload) .await; } @@ -265,6 +380,9 @@ impl QconnectEventSink for DaemonEventSink { renderer_state.current_position_ms, ); let mut sync_state = self.sync_state.lock().await; + if !self.is_current() { + return; + } cache_renderer_snapshot(&mut sync_state, renderer_state); } QconnectAppEvent::QueueUpdated(queue_state) => { @@ -277,8 +395,14 @@ impl QconnectEventSink for DaemonEventSink { ); { let mut sync_state = self.sync_state.lock().await; + if !self.is_current() { + return; + } sync_state.last_remote_queue_state = Some(queue_state.clone()); } + if !self.is_current() { + return; + } if let Err(err) = qconnect_app::renderer::materialize_remote_queue( &self.engine, &self.sync_state, @@ -286,13 +410,21 @@ impl QconnectEventSink for DaemonEventSink { ) .await { + if !self.is_current() { + return; + } log::warn!("[QConnect] Failed to materialize remote queue: {err}"); } } QconnectAppEvent::RendererCommandApplied { command, state } => { - log::info!("[QConnect] Renderer command applied: {:?}", command); - let became_active = - matches!(command, RendererCommand::SetActive { active: true }); + log::info!( + "[QConnect] Renderer command applied: {}", + renderer_command_name(command) + ); + let became_active = matches!(command, RendererCommand::SetActive { active: true }); + if !self.is_current() { + return; + } if let Err(err) = qconnect_app::renderer::apply_renderer_command( &self.engine, &self.sync_state, @@ -301,6 +433,9 @@ impl QconnectEventSink for DaemonEventSink { ) .await { + if !self.is_current() { + return; + } log::warn!("[QConnect] Failed to apply renderer command: {err}"); } else if became_active { self.report_active_renderer_ready().await; @@ -330,9 +465,7 @@ impl QconnectEventSink for DaemonEventSink { QconnectAppEvent::LifecycleChanged { state } => { log::info!("[QConnect] Lifecycle -> {state:?}"); } - QconnectAppEvent::Diagnostic { - channel, level, .. - } => { + QconnectAppEvent::Diagnostic { channel, level, .. } => { log::debug!("[QConnect] diagnostic {channel} [{level}]"); } _ => {} diff --git a/crates/qbzd/src/qconnect/transport.rs b/crates/qbzd/src/qconnect/transport.rs index e98e483bf..397155c43 100644 --- a/crates/qbzd/src/qconnect/transport.rs +++ b/crates/qbzd/src/qconnect/transport.rs @@ -18,6 +18,7 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock}; use qbz_app::shell::AppRuntime; +use qbz_qobuz::ApiError; use qconnect_transport_ws::WsTransportConfig; use serde::{Deserialize, Serialize}; @@ -30,6 +31,61 @@ const DEFAULT_QCONNECT_SOFTWARE_PREFIX: &str = "qbz"; type Runtime = Arc>; +#[derive(Clone, Copy)] +enum QconnectCredentialFailure { + ApiClientUnavailable, + Network, + Authentication, + Authorization, + Offline, + RateLimited, + Server, + InvalidResponse, + ServiceResponse, + Client, + MissingEndpoint, +} + +impl QconnectCredentialFailure { + const fn category(self) -> &'static str { + match self { + Self::ApiClientUnavailable => "api-client-unavailable", + Self::Network => "network", + Self::Authentication => "authentication", + Self::Authorization => "authorization", + Self::Offline => "offline", + Self::RateLimited => "rate-limited", + Self::Server => "server", + Self::InvalidResponse => "invalid-response", + Self::ServiceResponse => "service-response", + Self::Client => "client", + Self::MissingEndpoint => "missing-endpoint", + } + } +} + +fn classify_qconnect_credential_error(error: &ApiError) -> QconnectCredentialFailure { + match error { + ApiError::NetworkError(_) => QconnectCredentialFailure::Network, + ApiError::AuthenticationError(_) + | ApiError::InvalidAppId + | ApiError::InvalidAppSecret + | ApiError::BundleExtractionError(_) => QconnectCredentialFailure::Authentication, + ApiError::IneligibleUser | ApiError::Forbidden(_) | ApiError::ForbiddenCircuitOpen(_) => { + QconnectCredentialFailure::Authorization + } + ApiError::OfflineMode => QconnectCredentialFailure::Offline, + ApiError::RateLimited(_) => QconnectCredentialFailure::RateLimited, + ApiError::ServerError(_) => QconnectCredentialFailure::Server, + ApiError::ParseError(_) => QconnectCredentialFailure::InvalidResponse, + ApiError::ApiResponse(_) => QconnectCredentialFailure::ServiceResponse, + ApiError::NonStreamable + | ApiError::InvalidQuality(_) + | ApiError::NoQualityAvailable + | ApiError::TrackUnavailable(_) => QconnectCredentialFailure::Client, + } +} + /// The daemon-root `qconnect_settings.db` path, set once by `qconnect::start` /// before any connect. Daemon adaptation: the desktop delegates the path to the /// GLOBAL `qbz_app::qconnect_identity`; the daemon points every device-identity @@ -464,14 +520,18 @@ pub async fn resolve_transport_config(runtime: &Runtime) -> Result { + Err(failure) if endpoint_url.is_some() => { log::warn!( - "[QConnect] qws/createToken auto-discovery failed, using provided endpoint: {err}" + "[QConnect] qws/createToken auto-discovery failed, using provided endpoint \ + (category={})", + failure.category() ); } - Err(err) => { + Err(failure) => { return Err(format!( - "QConnect endpoint_url is required (or QBZ_QCONNECT_WS_ENDPOINT). Auto-discovery via qws/createToken failed: {err}" + "QConnect endpoint_url is required (or QBZ_QCONNECT_WS_ENDPOINT). \ + Auto-discovery via qws/createToken failed (category={})", + failure.category() )); } } @@ -505,7 +565,7 @@ pub async fn resolve_transport_config(runtime: &Runtime) -> Result Result<(Option, Option), String> { +) -> Result<(Option, Option), QconnectCredentialFailure> { // The client is Option-wrapped (None before API init). let client = runtime .core() @@ -513,18 +573,18 @@ async fn fetch_qconnect_transport_credentials( .read() .await .clone() - .ok_or_else(|| "qws/createToken requires an initialized API client".to_string())?; + .ok_or(QconnectCredentialFailure::ApiClientUnavailable)?; let (endpoint, _expires_at, jwt) = client .create_qconnect_token() .await - .map_err(|err| format!("qws/createToken failed: {err}"))? + .map_err(|error| classify_qconnect_credential_error(&error))? .into_parts(); let endpoint_url = normalize_opt_string(endpoint); let jwt_qws = normalize_opt_string(Some(jwt)); if endpoint_url.is_none() { - return Err("qws/createToken response missing jwt_qws.endpoint".to_string()); + return Err(QconnectCredentialFailure::MissingEndpoint); } Ok((endpoint_url, jwt_qws)) diff --git a/crates/qbzd/src/scrobble_engine.rs b/crates/qbzd/src/scrobble_engine.rs index 449ec88d5..ce149fd56 100644 --- a/crates/qbzd/src/scrobble_engine.rs +++ b/crates/qbzd/src/scrobble_engine.rs @@ -1,7 +1,7 @@ // crates/qbzd/src/scrobble_engine.rs — scrobble-on-play (CONSOLE ext). // -// A daemon background task subscribing the DaemonAdapter CoreEvent bus. On -// `TrackStarted` it sends "now playing" to every ACTIVE provider; when the +// A daemon background task subscribing the authority-stamped playback-event +// bus. On `TrackStarted` it sends "now playing" to every ACTIVE provider; when the // track crosses the scrobble threshold (`qbz_app::scrobble_timing:: // scrobble_delay_secs` — Last.fm's played-half-or-4-min rule) it scrobbles // ONCE. Credentials are re-read from the canonical `ScrobblerSettingsStore` on @@ -20,6 +20,7 @@ // Connection is never held across an await: it is opened inside a // `spawn_blocking` for each queue/drain op (mirrors `qbz::scrobble`). use std::path::PathBuf; +use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use qbz_app::settings::scrobblers::{ScrobblerSettings, ScrobblerSettingsStore}; @@ -30,7 +31,9 @@ use qbz_models::{CoreEvent, QueueTrack}; use tokio::sync::broadcast; use tokio::task::JoinHandle; +use crate::events_bridge::AuthorityStampedEvent; use crate::paths::ProfileRoots; +use crate::qconnect::authority::{AuthorityCell, OwnerAuthorityToken}; /// How often the ListenBrainz offline queue is retried (plus once at task /// start). Live now-playing/scrobble submits are unaffected — the drain only @@ -40,6 +43,9 @@ const DRAIN_INTERVAL: Duration = Duration::from_secs(120); /// The track currently being timed for a scrobble. struct Playing { track: QueueTrack, + /// Exact owner observation that admitted this track start. A position from + /// any later owner generation must not finish this pending scrobble. + owner_token: OwnerAuthorityToken, /// Unix seconds when it started — Last.fm's scrobble timestamp. started_at: u64, /// Seconds into the track at which it becomes scrobble-eligible; `None` @@ -51,7 +57,11 @@ struct Playing { /// Spawn the scrobble-on-play task. Holds NO `Arc` (only the roots, /// its own store, and the bus receiver), so it is outside the §8.2 audio /// clock-release ordering — the caller aborts it for a clean shutdown. -pub fn spawn(roots: ProfileRoots, mut rx: broadcast::Receiver) -> JoinHandle<()> { +pub fn spawn( + roots: ProfileRoots, + mut rx: broadcast::Receiver, + authority: Arc, +) -> JoinHandle<()> { use broadcast::error::RecvError; tokio::spawn(async move { let store = match ScrobblerSettingsStore::new_at(&roots.data) { @@ -71,31 +81,62 @@ pub fn spawn(roots: ProfileRoots, mut rx: broadcast::Receiver) -> Joi // `biased`: live bus events take priority over the drain tick. tokio::select! { biased; - ev = rx.recv() => match ev { - Ok(CoreEvent::TrackStarted { track, .. }) => { - let settings = store.get_settings().unwrap_or_default(); - if !settings.enabled { + received = rx.recv() => match received { + Ok(stamped) => { + let Some(owner_token) = stamped.owner_token else { playing = None; continue; + }; + let Some(permit) = authority + .wait_for_exact_owner_action_permit(owner_token) + .await + else { + playing = None; + continue; + }; + if playing + .as_ref() + .is_some_and(|current| current.owner_token != owner_token) + { + playing = None; } - now_playing(&settings, &track).await; - playing = Some(Playing { - threshold: qbz_app::scrobble_timing::scrobble_delay_secs(track.duration_secs), - started_at: now_unix(), - track, - scrobbled: false, - }); - } - Ok(CoreEvent::PositionUpdated { position_secs, .. }) => { - if let Some(p) = playing.as_mut() { - if due(position_secs, p.threshold, p.scrobbled) { + + match stamped.event { + CoreEvent::TrackStarted { track, .. } => { let settings = store.get_settings().unwrap_or_default(); - scrobble(&settings, &p.track, p.started_at, &roots).await; - p.scrobbled = true; + if !settings.enabled { + playing = None; + continue; + } + now_playing(&settings, &track).await; + playing = Some(Playing { + threshold: qbz_app::scrobble_timing::scrobble_delay_secs(track.duration_secs), + started_at: now_unix(), + track, + owner_token, + scrobbled: false, + }); } + CoreEvent::PositionUpdated { position_secs, .. } => { + if let Some(p) = playing.as_mut() { + if due(position_secs, p.threshold, p.scrobbled) { + let settings = store.get_settings().unwrap_or_default(); + p.scrobbled = scrobble( + &settings, + &p.track, + p.started_at, + &roots, + ) + .await; + } + } + } + _ => {} } + // Keep the exact admission alive through every provider + // await and any failed-listen persistence above. + drop(permit); } - Ok(_) => {} Err(RecvError::Lagged(_)) => continue, Err(RecvError::Closed) => return, }, @@ -134,7 +175,12 @@ async fn now_playing(s: &ScrobblerSettings, t: &QueueTrack) { } } -async fn scrobble(s: &ScrobblerSettings, t: &QueueTrack, started_at: u64, roots: &ProfileRoots) { +async fn scrobble( + s: &ScrobblerSettings, + t: &QueueTrack, + started_at: u64, + roots: &ProfileRoots, +) -> bool { let album = album_opt(t); if s.lastfm_active() { let c = LastFmClient::with_session_key(s.lastfm_session_key.clone()); @@ -145,8 +191,15 @@ async fn scrobble(s: &ScrobblerSettings, t: &QueueTrack, started_at: u64, roots: } if s.listenbrainz_active() { let c = lb_client(s); - match c.submit_listen(&t.artist, &t.title, album, started_at as i64, None).await { - Ok(()) => log::info!("[scrobbler] listenbrainz submitted: {} — {}", t.artist, t.title), + match c + .submit_listen(&t.artist, &t.title, album, started_at as i64, None) + .await + { + Ok(()) => log::info!( + "[scrobbler] listenbrainz submitted: {} — {}", + t.artist, + t.title + ), Err(e) => { // Persist to the shared offline queue; the periodic drain retries it. log::warn!("[scrobbler] listenbrainz submit failed, queueing: {e}"); @@ -154,6 +207,7 @@ async fn scrobble(s: &ScrobblerSettings, t: &QueueTrack, started_at: u64, roots: } } } + true } /// Persist a failed listen into the SHARED `ListenBrainzCache.listen_queue` @@ -275,11 +329,16 @@ fn album_opt(t: &QueueTrack) -> Option<&str> { } fn now_unix() -> u64 { - SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) } #[cfg(test)] mod tests { + use crate::qconnect::authority::AuthorityOrigin; + use super::*; #[test] @@ -320,8 +379,31 @@ mod tests { #[test] fn album_opt_drops_empty_and_unknown() { - assert_eq!(album_opt(&qt("Light as a Feather")), Some("Light as a Feather")); + assert_eq!( + album_opt(&qt("Light as a Feather")), + Some("Light as a Feather") + ); assert_eq!(album_opt(&qt("")), None); assert_eq!(album_opt(&qt("Unknown Album")), None); } + + #[test] + fn stale_owner_generation_cannot_finish_pending_scrobble() { + let authority = Arc::new(AuthorityCell::new()); + let owner = authority.reserve(AuthorityOrigin::Owner); + assert!(authority.install(owner)); + let (old_token, permit) = authority + .try_owner_action_permit_observed() + .expect("owner observation"); + drop(permit); + + let guest = authority.reserve(AuthorityOrigin::Delegated { generation: 4 }); + assert!(authority.install(guest)); + let restored_owner = authority.reserve(AuthorityOrigin::Owner); + assert!(authority.install(restored_owner)); + + assert!(authority + .try_owner_action_permit_exact(old_token) + .is_none()); + } } diff --git a/crates/qbzd/src/state.rs b/crates/qbzd/src/state.rs index fbd2cc839..c7ddcc3d2 100644 --- a/crates/qbzd/src/state.rs +++ b/crates/qbzd/src/state.rs @@ -57,7 +57,8 @@ impl DaemonShared { /// `Mutex` guard as every other field here, not a /// synchronization primitive of its own. pub fn network_online(&self) -> bool { - self.network_online.load(std::sync::atomic::Ordering::Relaxed) + self.network_online + .load(std::sync::atomic::Ordering::Relaxed) } /// Latch the network-reachability signal. See the field doc for exactly @@ -94,13 +95,41 @@ pub fn token_fingerprint(token: &str) -> u64 { hasher.finish() } -#[derive(Debug, Default, Clone, serde::Serialize)] +#[derive(Debug, Clone, serde::Serialize)] pub struct QconnectStatus { pub enabled: bool, pub state: String, // "off"|"connecting"|"connected"|"retrying"|"exhausted" pub session_active: bool, pub device_name: String, pub last_transport_reconnect: Option, + /// LAN receiver lifecycle. Contract values are + /// off|binding|listening|validating|delegated|restoring|error. + pub lan_state: String, + /// Bound receiver port, published only while the LAN service is live. + pub lan_port: Option, + /// Credential authority currently driving QConnect: owner|delegated. + pub credential_origin: String, + /// Latest candidate under validation/commit, never a session identifier. + pub candidate_generation: Option, + /// Sanitized LAN failure code. Never contains an endpoint, JWT, body, or IP. + pub last_lan_error: Option, +} + +impl Default for QconnectStatus { + fn default() -> Self { + Self { + enabled: false, + state: String::new(), + session_active: false, + device_name: String::new(), + last_transport_reconnect: None, + lan_state: "off".to_string(), + lan_port: None, + credential_origin: "owner".to_string(), + candidate_generation: None, + last_lan_error: None, + } + } } #[cfg(test)] @@ -123,6 +152,28 @@ mod tests { assert!(!q.session_active); assert_eq!(q.device_name, ""); assert!(q.last_transport_reconnect.is_none()); + assert_eq!(q.lan_state, "off"); + assert!(q.lan_port.is_none()); + assert_eq!(q.credential_origin, "owner"); + assert!(q.candidate_generation.is_none()); + assert!(q.last_lan_error.is_none()); + } + + #[test] + fn qconnect_lan_observability_serializes_with_contract_field_names() { + let mut q = QconnectStatus::default(); + q.lan_state = "validating".to_string(); + q.lan_port = Some(49_152); + q.credential_origin = "delegated".to_string(); + q.candidate_generation = Some(7); + q.last_lan_error = Some("qws_auth_rejected".to_string()); + + let value = serde_json::to_value(q).expect("serialize QConnect status"); + assert_eq!(value["lan_state"], "validating"); + assert_eq!(value["lan_port"], 49_152); + assert_eq!(value["credential_origin"], "delegated"); + assert_eq!(value["candidate_generation"], 7); + assert_eq!(value["last_lan_error"], "qws_auth_rejected"); } #[test]