refactor(qconnect): route liveness/lifecycle/diagnostic emits through event sink (slice 3)

Add five QconnectAppEvent variants (RendererUnreachable, RendererDisconnected,
ResyncComplete, LifecycleChanged, Diagnostic) and a Tauri mapper so the
session/liveness/diagnostic signals flow through the QconnectEventSink trait
instead of raw app_handle.emit calls. Move QconnectLifecycleState into
qconnect-app (pure data); Tauri types.rs re-exports it for the command surface.

No logic moves: the session logic still lives Tauri-side but now calls
sink.on_event(variant) at each rewired emit site. The sink mapper turns each
variant back into the exact same qconnect:* channel + payload, so the Svelte
wire surface is byte-identical. Each dedicated channel still fires exactly once
per occurrence (raw emits are replaced, not duplicated). The blanket
qconnect:event emit now also carries the new variants, which is additive and
ignored by the Svelte listener (pattern-matches known keys only).

Rewired emit sites:
- event_sink.rs freeze helper now takes/emits the freeze variant
  (RendererUnreachable/RendererDisconnected); the mapper emits the dedicated
  channel. State mutation precedes on_event and the variant arms only emit, so
  there is no re-entrancy/recursion into the watchdog/session dispatch.
- service.rs resync-complete -> ResyncComplete; inbound_queue_event/cloud_error/
  max_reconnect -> Diagnostic; update_lifecycle_state_if_running -> LifecycleChanged.

Tests: add qconnect-app unit tests asserting the new variants flow through a
sink and that QconnectLifecycleState serializes snake_case (matching the wire
payload). crates green (qconnect-app 33 passed); src-tauri cargo test qconnect
42 passed.

cc
This commit is contained in:
vicrodh committed 2026-06-01 09:20:12 -06:00
1 parent f907d9d984
commit 63d9f6b496
6 files changed
+233 -71

No files matched your search

+102
View File
@@ -3,6 +3,8 @@ use qconnect_core::{QConnectQueueState, QConnectRendererState, RendererCommand};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::session::QconnectLifecycleState;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum QconnectAppEvent {
TransportConnected,
@@ -45,6 +47,21 @@ pub enum QconnectAppEvent {
message_type: String,
payload: Value,
},
/// Active peer renderer went silent >=12s while PLAYING (liveness watchdog).
RendererUnreachable { renderer_id: i32 },
/// Active peer renderer left gracefully (status == ACTIVE_DISCONNECTED).
RendererDisconnected { renderer_id: i32 },
/// Post-reconnect resync has been issued (safe-to-replay advisory).
ResyncComplete,
/// Granular connection lifecycle changed (Connecting/Reconnecting/Connected/Exhausted/Off).
LifecycleChanged { state: QconnectLifecycleState },
/// Pass-through diagnostic (inbound-event preview, cloud error, max-reconnect, ...).
/// `channel` is the legacy Tauri channel suffix so the mapper is mechanical.
Diagnostic {
channel: String,
level: String,
payload: Value,
},
}
#[async_trait]
@@ -59,3 +76,88 @@ pub struct NoOpEventSink;
impl QconnectEventSink for NoOpEventSink {
async fn on_event(&self, _event: QconnectAppEvent) {}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use tokio::sync::Mutex;
use super::*;
#[derive(Debug, Default, Clone)]
struct TestSink {
events: Arc<Mutex<Vec<QconnectAppEvent>>>,
}
#[async_trait]
impl QconnectEventSink for TestSink {
async fn on_event(&self, event: QconnectAppEvent) {
self.events.lock().await.push(event);
}
}
/// Slice 3: the five new liveness/lifecycle/diagnostic variants flow through
/// the `QconnectEventSink` trait. A sink receives each one verbatim.
#[tokio::test]
async fn new_variants_flow_through_sink() {
let sink = TestSink::default();
sink.on_event(QconnectAppEvent::RendererUnreachable { renderer_id: 7 })
.await;
sink.on_event(QconnectAppEvent::RendererDisconnected { renderer_id: 9 })
.await;
sink.on_event(QconnectAppEvent::ResyncComplete).await;
sink.on_event(QconnectAppEvent::LifecycleChanged {
state: QconnectLifecycleState::Reconnecting,
})
.await;
sink.on_event(QconnectAppEvent::Diagnostic {
channel: "qconnect:cloud_error".to_string(),
level: "warning".to_string(),
payload: serde_json::json!({ "code": 13 }),
})
.await;
let events = sink.events.lock().await.clone();
assert_eq!(events.len(), 5);
assert!(matches!(
events[0],
QconnectAppEvent::RendererUnreachable { renderer_id: 7 }
));
assert!(matches!(
events[1],
QconnectAppEvent::RendererDisconnected { renderer_id: 9 }
));
assert!(matches!(events[2], QconnectAppEvent::ResyncComplete));
assert!(matches!(
events[3],
QconnectAppEvent::LifecycleChanged {
state: QconnectLifecycleState::Reconnecting
}
));
assert!(matches!(
&events[4],
QconnectAppEvent::Diagnostic { channel, level, .. }
if channel == "qconnect:cloud_error" && level == "warning"
));
}
/// The lifecycle state must serialize snake_case so the Tauri mapper's
/// `{"state": <serialized>}` payload is byte-identical to the prior raw
/// `qconnect:status_changed` emit.
#[test]
fn lifecycle_state_serializes_snake_case() {
assert_eq!(
serde_json::to_value(QconnectLifecycleState::Reconnecting).unwrap(),
serde_json::json!("reconnecting")
);
assert_eq!(
serde_json::to_value(QconnectLifecycleState::Connected).unwrap(),
serde_json::json!("connected")
);
assert_eq!(
serde_json::to_value(QconnectLifecycleState::Exhausted).unwrap(),
serde_json::json!("exhausted")
);
}
}
+3 -2
View File
@@ -16,8 +16,9 @@ pub use error::QconnectAppError;
pub use events::{NoOpEventSink, QconnectAppEvent, QconnectEventSink};
pub use session::{
compute_connection_state, deferred_join_reason, quality_from_max_audio_quality,
should_arm_renderer_watchdog, should_reask_queue_state, ConnectionDecision, RendererStatus,
ServerActiveState, JOIN_SESSION_REASON_CONTROLLER_REQUEST, JOIN_SESSION_REASON_RECONNECTION,
should_arm_renderer_watchdog, should_reask_queue_state, ConnectionDecision,
QconnectLifecycleState, RendererStatus, ServerActiveState,
JOIN_SESSION_REASON_CONTROLLER_REQUEST, JOIN_SESSION_REASON_RECONNECTION,
QCONNECT_RENDERER_LOST_TIMEOUT_MS,
};
pub use feature_flags::{
+29
View File
@@ -6,6 +6,35 @@
//! byte-for-byte identical to the prior Tauri-side definitions.
use qbz_models::Quality;
use serde::{Deserialize, Serialize};
/// 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).
///
/// Pure data — no Tauri dependency. The Tauri `types.rs` re-exports this for the
/// command surface; the session layer emits it via `QconnectAppEvent::LifecycleChanged`.
#[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,
}
/// Playing-state wire value for PLAYING. Mirrors the Tauri adapter's
/// `PLAYING_STATE_PLAYING` (the renderer reports playing_state == 2 while