fix(qbzd): latch network.online from real network-class outcomes

cc
This commit is contained in:
vicrodh committed 2026-07-14 21:59:50 -06:00
1 parent 47081b7666
commit 722a0207ec
5 files changed
+93 -3

No files matched your search

+4 -2
View File
@@ -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,
}
}
+23 -1
View File
@@ -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);
}
}
}
}
+8
View File
@@ -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);
}
}
}
+4
View File
@@ -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
+54
View File
@@ -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");