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:
vicrodh committed 2026-04-25 21:02:30 -06:00
1 parent d74332514d
commit 23658b053c
3 files changed
+209 -11

No files matched your search

+131 -2
View File
@@ -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,
}
}