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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,25 @@ import type {
|
||||
QconnectRendererSnapshot
|
||||
} from '$lib/services/qconnectRemoteQueue';
|
||||
|
||||
/**
|
||||
* Backend lifecycle state — kept in sync with `QconnectLifecycleState` in
|
||||
* `src-tauri/src/qconnect_service.rs`. The toggle's on/off display reads
|
||||
* `running` (derived from this), so a stuck reconnect loop is still visible as
|
||||
* "on" and the user can disable it (issue #358).
|
||||
*/
|
||||
export type QconnectLifecycleState =
|
||||
| 'off'
|
||||
| 'connecting'
|
||||
| 'connected'
|
||||
| 'reconnecting'
|
||||
| 'exhausted';
|
||||
|
||||
export type QconnectConnectionStatus = {
|
||||
running: boolean;
|
||||
transport_connected: boolean;
|
||||
endpoint_url?: string | null;
|
||||
last_error?: string | null;
|
||||
state?: QconnectLifecycleState;
|
||||
};
|
||||
|
||||
export type QconnectRendererInfo = {
|
||||
@@ -66,9 +80,20 @@ export const DEFAULT_QCONNECT_CONNECTION_STATUS: QconnectConnectionStatus = {
|
||||
running: false,
|
||||
transport_connected: false,
|
||||
endpoint_url: null,
|
||||
last_error: null
|
||||
last_error: null,
|
||||
state: 'off'
|
||||
};
|
||||
|
||||
/**
|
||||
* The toggle's on/off reading. Returns true when the user has enabled
|
||||
* QConnect even if the WS is currently re-establishing. This is required so a
|
||||
* stuck reconnect loop still shows as "on" and the user can disable it from
|
||||
* the UI (issue #358).
|
||||
*/
|
||||
export function isQconnectToggleOn(status: QconnectConnectionStatus): boolean {
|
||||
return Boolean(status.running) || status.state === 'connecting' || status.state === 'reconnecting';
|
||||
}
|
||||
|
||||
export const SHOW_QCONNECT_DEV_DIAGNOSTICS = import.meta.env.DEV;
|
||||
export const QCONNECT_DIAGNOSTIC_LOG_LIMIT = 200;
|
||||
|
||||
@@ -292,8 +317,17 @@ export async function fetchQconnectRuntimeState(): Promise<QconnectRuntimeStateS
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleQconnectConnection(connected: boolean): Promise<void> {
|
||||
if (connected) {
|
||||
/**
|
||||
* Toggle QConnect on/off based on whether the runtime is currently considered
|
||||
* "on" by the user. We must NOT base this on `transport_connected`: when the
|
||||
* reconnect loop is stuck (Reconnecting / Connecting), `transport_connected`
|
||||
* is false but the runtime is alive, and only `disconnect()` will tear it
|
||||
* down. Calling `connect()` in that state used to error with "already
|
||||
* running" — now it's a no-op and we still want the toggle to "turn it off".
|
||||
* (issue #358)
|
||||
*/
|
||||
export async function toggleQconnectConnection(toggleOn: boolean): Promise<void> {
|
||||
if (toggleOn) {
|
||||
await invoke('v2_qconnect_disconnect');
|
||||
return;
|
||||
}
|
||||
|
||||
+41
-6
@@ -371,6 +371,7 @@
|
||||
fetchQconnectRuntimeState,
|
||||
isQconnectPeerRendererActive,
|
||||
isQconnectRemoteModeActive as computeQconnectRemoteModeActive,
|
||||
isQconnectToggleOn,
|
||||
logQconnectPlaybackReport as appendQconnectPlaybackReport,
|
||||
qconnectAdmissionReasonKey,
|
||||
shouldQconnectSuppressLocalPlaybackAutomation,
|
||||
@@ -949,6 +950,14 @@
|
||||
let isQconnectPanelOpen = $state(false);
|
||||
let showQconnectDevButton = $state(localStorage.getItem('qbz-qconnect-dev-button') === 'true');
|
||||
let isQobuzConnectConnected = $state(false);
|
||||
/**
|
||||
* User-facing toggle state. True when the user has QConnect enabled even if
|
||||
* the WS is currently re-establishing (Connecting/Reconnecting). This is
|
||||
* separate from `isQobuzConnectConnected` so a stuck reconnect loop is
|
||||
* still visible as "on" in the UI, allowing the user to disable it
|
||||
* (issue #358).
|
||||
*/
|
||||
let isQobuzConnectToggleOn = $state(false);
|
||||
let qobuzConnectBusy = $state(false);
|
||||
let qobuzConnectRefreshBusy = $state(false);
|
||||
let qobuzConnectStatus = $state<QconnectConnectionStatus>(DEFAULT_QCONNECT_CONNECTION_STATUS);
|
||||
@@ -1147,8 +1156,15 @@
|
||||
|
||||
function applyQobuzConnectStatus(status: QconnectConnectionStatus): void {
|
||||
qobuzConnectStatus = status;
|
||||
const nextConnected = Boolean(status.transport_connected);
|
||||
isQobuzConnectConnected = nextConnected;
|
||||
// `isQobuzConnectConnected` reflects whether the WS is actually up — it
|
||||
// gates "send command to QConnect server" calls (volume reports, position
|
||||
// reports, etc.). It must NOT include Connecting/Reconnecting, otherwise
|
||||
// we'd send commands while the transport is down.
|
||||
isQobuzConnectConnected = Boolean(status.transport_connected);
|
||||
// `isQobuzConnectToggleOn` is the user-facing on/off — true even during a
|
||||
// stuck reconnect loop, so the user can disable it from the UI
|
||||
// (issue #358).
|
||||
isQobuzConnectToggleOn = isQconnectToggleOn(status);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
@@ -1314,7 +1330,11 @@
|
||||
if (qobuzConnectBusy) return;
|
||||
qobuzConnectBusy = true;
|
||||
try {
|
||||
await toggleQconnectConnection(isQobuzConnectConnected);
|
||||
// Use the user-facing toggle state, not transport_connected. During a
|
||||
// stuck reconnect loop `isQobuzConnectConnected` is false but
|
||||
// `isQobuzConnectToggleOn` is true — clicking the toggle must call
|
||||
// disconnect to break the loop (issue #358).
|
||||
await toggleQconnectConnection(isQobuzConnectToggleOn);
|
||||
} catch (err) {
|
||||
console.error('Qobuz Connect toggle failed:', err);
|
||||
pushQobuzConnectDiagnostic('toggle', 'error', err);
|
||||
@@ -4490,7 +4510,10 @@
|
||||
|
||||
void refreshQobuzConnectRuntimeState();
|
||||
const qobuzConnectStatusInterval = setInterval(() => {
|
||||
if (isQconnectPanelOpen || isQobuzConnectConnected) {
|
||||
// Poll the runtime when the panel is open OR the toggle is on — this
|
||||
// includes Connecting/Reconnecting/Exhausted, so the UI stays in sync
|
||||
// through state transitions (issue #358).
|
||||
if (isQconnectPanelOpen || isQobuzConnectToggleOn) {
|
||||
void refreshQobuzConnectRuntimeState();
|
||||
} else {
|
||||
void refreshQobuzConnectStatus();
|
||||
@@ -5128,6 +5151,7 @@
|
||||
let unlistenLinkResolved: UnlistenFn | null = null;
|
||||
let unlistenQconnectEvent: UnlistenFn | null = null;
|
||||
let unlistenQconnectError: UnlistenFn | null = null;
|
||||
let unlistenQconnectStatusChanged: UnlistenFn | null = null;
|
||||
let unlistenQconnectAdmissionBlocked: UnlistenFn | null = null;
|
||||
let unlistenQconnectDiagnostic: UnlistenFn | null = null;
|
||||
let unlistenQconnectRendererReportDebug: UnlistenFn | null = null;
|
||||
@@ -5301,6 +5325,16 @@
|
||||
if (disposed) { unlisten7(); return; }
|
||||
unlistenQconnectError = unlisten7;
|
||||
|
||||
// Backend emits this whenever the lifecycle transitions
|
||||
// (Connecting → Reconnecting → Exhausted, etc). Refresh status promptly
|
||||
// so the toggle reflects the new state without waiting for the 5s poll
|
||||
// (issue #358).
|
||||
const unlistenStatusChanged = await listen('qconnect:status_changed', () => {
|
||||
void refreshQobuzConnectStatus();
|
||||
});
|
||||
if (disposed) { unlistenStatusChanged(); return; }
|
||||
unlistenQconnectStatusChanged = unlistenStatusChanged;
|
||||
|
||||
const unlisten8 = await listen<QconnectAdmissionBlockedEvent>('qconnect:admission_blocked', (event) => {
|
||||
pushQobuzConnectDiagnostic('qconnect:admission_blocked', 'warn', event.payload);
|
||||
showToast($t(qconnectAdmissionReasonKey(event.payload.reason)), 'warning');
|
||||
@@ -5353,6 +5387,7 @@
|
||||
unlistenLinkResolved?.();
|
||||
unlistenQconnectEvent?.();
|
||||
unlistenQconnectError?.();
|
||||
unlistenQconnectStatusChanged?.();
|
||||
unlistenQconnectAdmissionBlocked?.();
|
||||
unlistenQconnectDiagnostic?.();
|
||||
unlistenQconnectRendererReportDebug?.();
|
||||
@@ -6482,7 +6517,7 @@
|
||||
onCast={openCastPicker}
|
||||
{isCastConnected}
|
||||
onQobuzConnect={openQobuzConnectPanelFromNowPlaying}
|
||||
{isQobuzConnectConnected}
|
||||
isQobuzConnectConnected={isQobuzConnectToggleOn}
|
||||
onToggleLyrics={toggleLyricsSidebar}
|
||||
lyricsActive={lyricsSidebarVisible}
|
||||
onArtistClick={() => {
|
||||
@@ -6529,7 +6564,7 @@
|
||||
onCast={openCastPicker}
|
||||
{isCastConnected}
|
||||
onQobuzConnect={openQobuzConnectPanelFromNowPlaying}
|
||||
{isQobuzConnectConnected}
|
||||
isQobuzConnectConnected={isQobuzConnectToggleOn}
|
||||
queueOpen={isQueueOpen}
|
||||
{volume}
|
||||
onVolumeChange={handleVolumeChange}
|
||||
|
||||
Reference in new issue
Block a user