fix(qbzd): latch network.online from real network-class outcomes
cc
This commit is contained in:
5 files changed
+93
-3
No files matched your search
@@ -111,7 +111,7 @@ pub fn status(state: &super::ApiState) -> Response<Cursor<Vec<u8>>> {
|
||||
fn assemble_live(state: &super::ApiState) -> StatusDoc {
|
||||
// 1. snapshot DaemonShared, then DROP the guard before any block_on so the
|
||||
// mutex is never held across an await point.
|
||||
let (auth, user_id, subscription, last_errors, qconnect, tick_age, muted, uptime) =
|
||||
let (auth, user_id, subscription, last_errors, qconnect, tick_age, muted, uptime, network_online) =
|
||||
match state.shared.lock() {
|
||||
Ok(s) => (
|
||||
s.auth,
|
||||
@@ -122,6 +122,7 @@ fn assemble_live(state: &super::ApiState) -> StatusDoc {
|
||||
s.driver_last_tick.map(|t| t.elapsed().as_millis() as u64),
|
||||
s.muted,
|
||||
s.started_at.elapsed().as_secs(),
|
||||
s.network_online(),
|
||||
),
|
||||
Err(_) => (
|
||||
AuthState::Restoring,
|
||||
@@ -132,6 +133,7 @@ fn assemble_live(state: &super::ApiState) -> StatusDoc {
|
||||
None,
|
||||
false,
|
||||
0,
|
||||
true,
|
||||
),
|
||||
};
|
||||
|
||||
@@ -201,7 +203,7 @@ fn assemble_live(state: &super::ApiState) -> StatusDoc {
|
||||
queue_len: queue.total_tracks,
|
||||
},
|
||||
qconnect,
|
||||
network: NetworkStatus { online: true },
|
||||
network: NetworkStatus { online: network_online },
|
||||
last_errors,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,6 +285,11 @@ async fn boot(roots: &ProfileRoots, cfg: &QbzdConfig, warn_count: usize) -> Resu
|
||||
Err(e) => {
|
||||
// network-class: KEEP token, stay Restoring, retry w/ backoff.
|
||||
log::warn!("session restore deferred (network-class): {e}");
|
||||
// 01 §9.3: a real network-class outcome — latch `network.online`
|
||||
// false so `/api/status` reflects it until a retry succeeds.
|
||||
if let Ok(s) = shared.lock() {
|
||||
s.set_network_online(false);
|
||||
}
|
||||
Some(spawn_auth_retry(
|
||||
runtime.clone(),
|
||||
shared.clone(),
|
||||
@@ -414,6 +419,10 @@ pub(crate) async fn restore_activate(
|
||||
// skip a redundant re-login on every unrelated settings nudge.
|
||||
if let Ok(mut s) = shared.lock() {
|
||||
s.credential_fingerprint = Some(crate::state::token_fingerprint(token));
|
||||
// 01 §9.3: a real login/restore success (boot, background auth-retry,
|
||||
// or a reload's credential re-validation — every caller of this fn)
|
||||
// means the network is reachable — latch `network.online` back true.
|
||||
s.set_network_online(true);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -434,6 +443,7 @@ fn new_shared(cfg: &QbzdConfig) -> Arc<Mutex<DaemonShared>> {
|
||||
startup_warnings: 0,
|
||||
qconnect: QconnectStatus::default(),
|
||||
credential_fingerprint: None,
|
||||
network_online: std::sync::atomic::AtomicBool::new(true),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -529,13 +539,21 @@ fn spawn_auth_retry(
|
||||
set_needs_auth(&shared, Some(e));
|
||||
return;
|
||||
}
|
||||
Err(e) => log::warn!("session restore retry {} failed (network-class): {e}", i + 1),
|
||||
Err(e) => {
|
||||
log::warn!("session restore retry {} failed (network-class): {e}", i + 1);
|
||||
// 01 §9.3: latch `network.online` false on every real
|
||||
// network-class outcome, not just the first.
|
||||
if let Ok(s) = shared.lock() {
|
||||
s.set_network_online(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Schedule exhausted with only network-class failures: KEEP the token,
|
||||
// surface NeedsAuth, latch the reason for `qbzd status`.
|
||||
if let Ok(mut s) = shared.lock() {
|
||||
s.auth = AuthState::NeedsAuth;
|
||||
s.set_network_online(false);
|
||||
s.last_errors.auth = Some(
|
||||
"could not reach Qobuz to restore the saved session — token kept, retry with 'qbzd login' or 'qbzd settings reload'".into(),
|
||||
);
|
||||
@@ -840,6 +858,10 @@ pub(crate) async fn reload_credentials(
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[reload] session restore deferred (network-class): {e}");
|
||||
// 01 §9.3: real network-class outcome — latch it false.
|
||||
if let Ok(s) = shared.lock() {
|
||||
s.set_network_online(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,14 @@ fn latch_lifecycle_into_shared(shared: &SharedState, state: QconnectLifecycleSta
|
||||
if matches!(state, QconnectLifecycleState::Reconnecting) {
|
||||
s.qconnect.last_transport_reconnect = Some(unix_seconds_string());
|
||||
}
|
||||
// 01 §9.3: a live QConnect session (fresh connect OR post-reconnect
|
||||
// recovery) is a real network-reachable outcome — latch it true. The
|
||||
// reconnect-EXHAUSTED failure side latches false in
|
||||
// `session::DaemonSessionLoopHost::on_reconnect_exhausted` (it does
|
||||
// not route through this helper — see there).
|
||||
if matches!(state, QconnectLifecycleState::Connected) {
|
||||
s.set_network_online(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -125,6 +125,10 @@ impl SessionLoopHost for DaemonSessionLoopHost {
|
||||
if let Ok(mut s) = self.shared.lock() {
|
||||
s.qconnect.state = "exhausted".to_string();
|
||||
s.qconnect.session_active = false;
|
||||
// 01 §9.3: reconnect-exhausted is a real network-class failure —
|
||||
// latch `network.online` false (the success side latches true in
|
||||
// `latch_lifecycle_into_shared` on the next `Connected` transition).
|
||||
s.set_network_online(false);
|
||||
}
|
||||
log::warn!("[QConnect] Reconnect exhausted ({attempts}): {last_reason}");
|
||||
// Daemon has no offline MODE, so the desktop's idle-retry offline park is
|
||||
|
||||
@@ -37,6 +37,31 @@ pub struct DaemonShared {
|
||||
/// `None` whenever the daemon is not LoggedIn against a known token
|
||||
/// (cleared alongside every `set_needs_auth`).
|
||||
pub credential_fingerprint: Option<u64>,
|
||||
/// Coarse network-reachability signal for `/api/status`'s `network.online`
|
||||
/// (01 §9.3). Latched ONLY from real network-class outcomes — never active
|
||||
/// probing: false on an auth-retry/credential-reload network-class failure
|
||||
/// or a QConnect reconnect-exhausted, true on a successful login/restore
|
||||
/// (`restore_activate`, which reload's own credential validation also
|
||||
/// funnels through) or a successful QConnect (re)connect. Defaults true
|
||||
/// (optimistic) until the first outcome latches it.
|
||||
pub network_online: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl DaemonShared {
|
||||
/// Read the latched network-reachability signal (§9.3). `Relaxed` is
|
||||
/// sufficient — this is a coarse status flag read under the same
|
||||
/// `Mutex<DaemonShared>` 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)
|
||||
}
|
||||
|
||||
/// Latch the network-reachability signal. See the field doc for exactly
|
||||
/// which call sites are allowed to call this.
|
||||
pub fn set_network_online(&self, online: bool) {
|
||||
self.network_online
|
||||
.store(online, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// A non-reversible-in-practice fingerprint of a credential token (SipHash via
|
||||
@@ -115,11 +140,40 @@ mod tests {
|
||||
startup_warnings: 0,
|
||||
qconnect: QconnectStatus::default(),
|
||||
credential_fingerprint: None,
|
||||
network_online: std::sync::atomic::AtomicBool::new(true),
|
||||
};
|
||||
assert_eq!(shared.auth, AuthState::LoggedIn);
|
||||
assert_eq!(shared.user_id, Some(1234567));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_online_latches_false_then_true() {
|
||||
// Pure latch semantics (01 §9.3): a real network-class failure flips
|
||||
// it false, a real success flips it back true — the exact two
|
||||
// transitions every call site above drives. Defaults true.
|
||||
let shared = DaemonShared {
|
||||
auth: AuthState::Restoring,
|
||||
user_id: None,
|
||||
subscription: None,
|
||||
last_errors: LatchedErrors::default(),
|
||||
driver_last_tick: None,
|
||||
muted: false,
|
||||
premute_volume: 1.0,
|
||||
started_at: std::time::Instant::now(),
|
||||
startup_warnings: 0,
|
||||
qconnect: QconnectStatus::default(),
|
||||
credential_fingerprint: None,
|
||||
network_online: std::sync::atomic::AtomicBool::new(true),
|
||||
};
|
||||
assert!(shared.network_online(), "defaults true (optimistic)");
|
||||
|
||||
shared.set_network_online(false);
|
||||
assert!(!shared.network_online(), "set false -> reads back false");
|
||||
|
||||
shared.set_network_online(true);
|
||||
assert!(shared.network_online(), "set true -> reads back true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_fingerprint_is_stable_and_distinguishes_tokens() {
|
||||
let a = token_fingerprint("token-a");
|
||||
|
||||
Reference in new issue
Block a user