fix(qconnect): decouple toggle state from transport so user can disable a stuck reconnect
When the reconnect loop is stuck waiting for a SESSION_STATE that never comes (the actual symptom of issue #358), the runtime is alive but `transport_connected` is false. The UI used to read its on/off toggle from `transport_connected`, so the badge appeared OFF while the backend loop kept retrying. Worse: clicking the toggle in that state called `connect()`, which rejected with "QConnect service is already running". The user had no way to disable QConnect from the UI — only to "turn it on more", which failed. Backend (`qconnect_service.rs`): - Add `QconnectLifecycleState` enum (Off / Connecting / Connected / Reconnecting / Exhausted) and surface it on `QconnectConnectionStatus::state`. Driven from the spawned transport event loop via a new `update_lifecycle_state_if_running` helper which refuses to overwrite terminal states (Off / Exhausted) with late Reconnecting / Connected events from the broadcast queue. - Make `connect()` idempotent: if a runtime already exists (alive but reconnecting), return current status instead of erroring. The toggle is the single user-visible control; the previous "already running" error was unactionable from the UI. - Auto-stop on `MaxReconnectAttemptsExceeded`: take the runtime out, set state=Exhausted, set last_error to the cloud reason, and emit a `qconnect:status_changed` event so the frontend reflects the new state without waiting for its 5 s poll. The transport loop has already exited at this point, so a fresh user-initiated connect() cleanly starts a new runtime. - `disconnect()` now forces lifecycle to Off unconditionally and tears down the runtime (already worked, the transport's `shutdown_tx` watch breaks `handle_reconnect_delay` mid-sleep), satisfying the "disable QConnect must always succeed" requirement. Frontend (`qconnectRuntime.ts`, `+page.svelte`): - Add `QconnectLifecycleState` mirror type and `isQconnectToggleOn()` helper. - Split state in the page: `isQobuzConnectConnected` keeps its old semantics (`transport_connected`) and gates send-command paths (volume reports, position reports) so we don't fire commands while the WS is down. New `isQobuzConnectToggleOn` reflects the user-facing on/off — true even during Reconnecting/Connecting — and is what the badge component and the toggle handler now read. - Listen for the new `qconnect:status_changed` event so the toggle reflects auto-stop (Exhausted) and other transitions immediately instead of after the 5 s status poll. Refs #358 cc
This commit is contained in:
3 files changed
+209
-11
No files matched your search
@@ -66,12 +66,42 @@ pub struct QconnectConnectOptions {
|
||||
pub subscribe_channels_hex: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Lifecycle state surfaced to the UI so the toggle can reflect what the user
|
||||
/// asked for (`running`) separately from what the transport currently has
|
||||
/// (`Connecting`/`Connected`/`Reconnecting`). Without this distinction the UI
|
||||
/// reads the toggle as "off" while the backend reconnect loop is alive,
|
||||
/// leaving the user unable to disable QConnect (issue #358).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum QconnectLifecycleState {
|
||||
/// User has not enabled QConnect (or it has been fully torn down).
|
||||
#[default]
|
||||
Off,
|
||||
/// `connect()` has been called; transport is establishing the WS but no
|
||||
/// `SessionEstablished` yet.
|
||||
Connecting,
|
||||
/// Transport saw at least one `SESSION_STATE` frame on the active WS — the
|
||||
/// session-level handshake completed.
|
||||
Connected,
|
||||
/// Transport disconnected after at least one successful connect; the
|
||||
/// reconnect loop is running.
|
||||
Reconnecting,
|
||||
/// `MaxReconnectAttemptsExceeded` fired — runtime auto-stopped, last error
|
||||
/// surfaced. User can re-enable from UI.
|
||||
Exhausted,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct QconnectConnectionStatus {
|
||||
pub running: bool,
|
||||
pub transport_connected: bool,
|
||||
pub endpoint_url: Option<String>,
|
||||
pub last_error: Option<String>,
|
||||
/// Granular lifecycle state — the toggle should base its on/off reading on
|
||||
/// this rather than on `transport_connected`, so a stuck reconnect loop is
|
||||
/// still visible as "on" and the user can disable it (issue #358).
|
||||
#[serde(default)]
|
||||
pub state: QconnectLifecycleState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@@ -301,6 +331,9 @@ struct QconnectRuntime {
|
||||
struct QconnectServiceInner {
|
||||
runtime: Option<QconnectRuntime>,
|
||||
last_error: Option<String>,
|
||||
/// Lifecycle state — driven by transport events from the spawned event
|
||||
/// loop. See `QconnectLifecycleState` (issue #358).
|
||||
lifecycle_state: QconnectLifecycleState,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -364,6 +397,38 @@ fn queue_payload_track_preview(payload: &Value, key: &str) -> Vec<i64> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Update `QconnectServiceInner::lifecycle_state` from inside the spawned
|
||||
/// transport-event loop, but only while the runtime is still alive. If
|
||||
/// `disconnect()` already took the runtime, or `MaxReconnectAttemptsExceeded`
|
||||
/// already moved us to `Exhausted`, we must not stomp those terminal states
|
||||
/// with a late `Reconnecting`/`Connected` arriving from the broadcast queue
|
||||
/// (issue #358).
|
||||
async fn update_lifecycle_state_if_running(
|
||||
inner: &Arc<Mutex<QconnectServiceInner>>,
|
||||
app_handle: &AppHandle,
|
||||
next: QconnectLifecycleState,
|
||||
) {
|
||||
let mut guard = inner.lock().await;
|
||||
if guard.runtime.is_none() {
|
||||
return;
|
||||
}
|
||||
if guard.lifecycle_state == next {
|
||||
return;
|
||||
}
|
||||
guard.lifecycle_state = next;
|
||||
drop(guard);
|
||||
let serialized = serde_json::to_string(&next)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<Value>(&s).ok())
|
||||
.unwrap_or_else(|| json!("unknown"));
|
||||
let _ = app_handle.emit(
|
||||
"qconnect:status_changed",
|
||||
json!({
|
||||
"state": serialized,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
fn emit_qconnect_diagnostic(app_handle: &AppHandle, channel: &str, level: &str, payload: Value) {
|
||||
let ts = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -1218,9 +1283,20 @@ impl QconnectServiceState {
|
||||
}
|
||||
|
||||
let mut guard = self.inner.lock().await;
|
||||
// Idempotent connect: if a runtime is already alive (including stuck in
|
||||
// the reconnect loop), don't error — return the current status. The UI
|
||||
// toggle reads `running` so the badge stays "on" and clicking it routes
|
||||
// to disconnect, which DOES break the loop (issue #358).
|
||||
if guard.runtime.is_some() {
|
||||
return Err("QConnect service is already running".to_string());
|
||||
log::info!(
|
||||
"[QConnect] connect() called while runtime is alive (state={:?}); returning current status",
|
||||
guard.lifecycle_state
|
||||
);
|
||||
drop(guard);
|
||||
return Ok(self.status().await);
|
||||
}
|
||||
guard.lifecycle_state = QconnectLifecycleState::Connecting;
|
||||
guard.last_error = None;
|
||||
|
||||
let transport = Arc::new(NativeWsTransport::new());
|
||||
let sync_state = Arc::new(Mutex::new(QconnectRemoteSyncState::default()));
|
||||
@@ -1238,6 +1314,8 @@ impl QconnectServiceState {
|
||||
let mut transport_rx = app.subscribe_transport_events();
|
||||
let app_for_loop = Arc::clone(&app);
|
||||
let app_for_errors = app_handle.clone();
|
||||
let inner_for_loop = Arc::clone(&self.inner);
|
||||
let app_handle_for_status = app_handle.clone();
|
||||
|
||||
let event_loop = tauri::async_runtime::spawn(async move {
|
||||
log::info!("[QConnect/EventLoop] Started listening for transport events");
|
||||
@@ -1272,6 +1350,16 @@ impl QconnectServiceState {
|
||||
log::warn!("[QConnect/Transport] WebSocket disconnected — resetting renderer_joined flag");
|
||||
renderer_joined = false;
|
||||
has_disconnected = true;
|
||||
// Surface "Reconnecting" to the UI, but only if
|
||||
// we're not in a teardown path (Off/Exhausted).
|
||||
// The reconnect loop will keep retrying until
|
||||
// SessionEstablished or MaxReconnectAttemptsExceeded.
|
||||
update_lifecycle_state_if_running(
|
||||
&inner_for_loop,
|
||||
&app_handle_for_status,
|
||||
QconnectLifecycleState::Reconnecting,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
qconnect_transport_ws::TransportEvent::Authenticated => {
|
||||
log::info!("[QConnect/Transport] Authenticated with JWT");
|
||||
@@ -1397,6 +1485,12 @@ impl QconnectServiceState {
|
||||
log::info!(
|
||||
"[QConnect/Transport] Session established — backoff counters reset"
|
||||
);
|
||||
update_lifecycle_state_if_running(
|
||||
&inner_for_loop,
|
||||
&app_handle_for_status,
|
||||
QconnectLifecycleState::Connected,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
qconnect_transport_ws::TransportEvent::MaxReconnectAttemptsExceeded {
|
||||
attempts,
|
||||
@@ -1416,6 +1510,33 @@ impl QconnectServiceState {
|
||||
"last_reason": last_reason,
|
||||
}),
|
||||
);
|
||||
// Auto-stop the runtime: the transport loop
|
||||
// already broke (Exhausted), so no more events
|
||||
// will fire. Take the runtime out so a fresh
|
||||
// user-initiated `connect()` succeeds, mark the
|
||||
// lifecycle as Exhausted with the cloud-side
|
||||
// reason, and exit the event loop. The runtime
|
||||
// we just dropped owns this very task's
|
||||
// JoinHandle — that's fine, dropping a handle
|
||||
// detaches; we then `break` so the task ends
|
||||
// naturally (issue #358).
|
||||
let mut guard = inner_for_loop.lock().await;
|
||||
guard.lifecycle_state = QconnectLifecycleState::Exhausted;
|
||||
guard.last_error = Some(format!(
|
||||
"Reconnect attempts exhausted ({attempts}): {last_reason}"
|
||||
));
|
||||
guard.runtime = None;
|
||||
drop(guard);
|
||||
let _ = app_handle_for_status.emit(
|
||||
"qconnect:status_changed",
|
||||
json!({
|
||||
"state": "exhausted",
|
||||
"reason": "max_reconnect_attempts_exceeded",
|
||||
"attempts": attempts,
|
||||
"last_reason": last_reason,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Err(err) = app_for_loop.handle_transport_event(event).await {
|
||||
@@ -1461,6 +1582,12 @@ impl QconnectServiceState {
|
||||
pub async fn disconnect(&self) -> Result<QconnectConnectionStatus, String> {
|
||||
let runtime = {
|
||||
let mut guard = self.inner.lock().await;
|
||||
// Always force lifecycle to Off — the user-facing requirement is
|
||||
// that "disable QConnect" must succeed regardless of whether the
|
||||
// backend is Connecting / Reconnecting / Connected / Exhausted
|
||||
// (issue #358). The transport's shutdown_tx watch + the runtime
|
||||
// event_loop.abort below will tear down any in-flight reconnect.
|
||||
guard.lifecycle_state = QconnectLifecycleState::Off;
|
||||
guard.runtime.take()
|
||||
};
|
||||
|
||||
@@ -1476,7 +1603,7 @@ impl QconnectServiceState {
|
||||
}
|
||||
|
||||
pub async fn status(&self) -> QconnectConnectionStatus {
|
||||
let (app, endpoint_url, last_error) = {
|
||||
let (app, endpoint_url, last_error, lifecycle_state) = {
|
||||
let guard = self.inner.lock().await;
|
||||
(
|
||||
guard
|
||||
@@ -1488,6 +1615,7 @@ impl QconnectServiceState {
|
||||
.as_ref()
|
||||
.map(|runtime| runtime.config.endpoint_url.clone()),
|
||||
guard.last_error.clone(),
|
||||
guard.lifecycle_state,
|
||||
)
|
||||
};
|
||||
|
||||
@@ -1502,6 +1630,7 @@ impl QconnectServiceState {
|
||||
transport_connected,
|
||||
endpoint_url,
|
||||
last_error,
|
||||
state: lifecycle_state,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user