fix(qconnect): enforce shared lifecycle authority

cx
This commit is contained in:
vicrodh committed 2026-08-30 22:22:08 -06:00
1 parent 74a41150e9
commit b4404f45ba
22 files changed
+3233 -406

No files matched your search

+268 -26
View File
@@ -1,11 +1,13 @@
use std::{
sync::Arc,
collections::VecDeque,
sync::{Arc, Mutex as StdMutex},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use qconnect_core::{
apply_event, apply_renderer_command, telemetry, PendingCorrelation, PendingQueueAction,
QConnectQueueState, QConnectRendererState, QueueEvent, QueueItem, QueueVersion, RendererCommand,
QConnectQueueState, QConnectRendererState, QueueEvent, QueueItem, QueueVersion,
RendererCommand,
};
use qconnect_protocol::{
build_qconnect_outbound_envelope, build_qconnect_renderer_outbound_envelope,
@@ -52,6 +54,17 @@ where
/// (slice 6) both lock THIS one, exactly as they share it today via the Tauri
/// adapter. See `MASTER-qconnect-to-slint-plan.md` §0 (one Mutex, not two).
sync: Arc<Mutex<QconnectRemoteSyncState>>,
/// Timers belong to this app instance, not to the process runtime. Keeping
/// their handles here lets disconnect/retirement abort them synchronously
/// before an old sink (and its renderer engine/stream feeder) can outlive
/// the authority that created it.
background_tasks: Arc<StdMutex<BackgroundTasks>>,
}
#[derive(Default)]
struct BackgroundTasks {
accepting: bool,
handles: Vec<tokio::task::JoinHandle<()>>,
}
impl<TTransport, TSink> Clone for QconnectApp<TTransport, TSink>
@@ -65,6 +78,7 @@ where
sink: Arc::clone(&self.sink),
state: Arc::clone(&self.state),
sync: Arc::clone(&self.sync),
background_tasks: Arc::clone(&self.background_tasks),
}
}
}
@@ -90,6 +104,10 @@ where
sink,
state: Arc::new(Mutex::new(QconnectRuntimeState::default())),
sync,
background_tasks: Arc::new(StdMutex::new(BackgroundTasks {
accepting: true,
handles: Vec::new(),
})),
}
}
@@ -110,6 +128,7 @@ where
pub async fn connect(&self, config: WsTransportConfig) -> Result<(), QconnectAppError> {
self.transport.connect(config).await?;
self.set_background_tasks_accepting(true);
{
let mut state = self.state.lock().await;
state.transport_connected = true;
@@ -121,7 +140,8 @@ where
}
pub async fn disconnect(&self) -> Result<(), QconnectAppError> {
self.transport.disconnect().await?;
self.abort_background_tasks().await;
let transport_result = self.transport.disconnect().await;
{
let mut state = self.state.lock().await;
state.transport_connected = false;
@@ -130,7 +150,7 @@ where
self.sink
.on_event(QconnectAppEvent::TransportDisconnected)
.await;
Ok(())
transport_result.map_err(Into::into)
}
pub async fn queue_state_snapshot(&self) -> QConnectQueueState {
@@ -573,9 +593,10 @@ where
fn spawn_pending_timeout_watch(&self, action_uuid: String) {
let app = self.clone();
tokio::spawn(async move {
let handle = tokio::spawn(async move {
app.watch_pending_action_timeout(action_uuid).await;
});
self.own_background_task(handle);
}
async fn watch_pending_action_timeout(&self, action_uuid: String) {
@@ -1481,9 +1502,48 @@ where
/// `watch_pending_action_timeout`; relocated from the Tauri sink (slice 2+4).
pub fn arm_renderer_watchdog(&self, renderer_id: i32, generation: u64) {
let app = self.clone();
tokio::spawn(async move {
let handle = tokio::spawn(async move {
app.run_renderer_watchdog(renderer_id, generation).await;
});
self.own_background_task(handle);
}
fn own_background_task(&self, handle: tokio::task::JoinHandle<()>) {
let mut tasks = self
.background_tasks
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if !tasks.accepting {
handle.abort();
return;
}
tasks.handles.retain(|task| !task.is_finished());
tasks.handles.push(handle);
}
fn set_background_tasks_accepting(&self, accepting: bool) {
let mut tasks = self
.background_tasks
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
tasks.accepting = accepting;
}
async fn abort_background_tasks(&self) {
let handles = {
let mut tasks = self
.background_tasks
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
tasks.accepting = false;
std::mem::take(&mut tasks.handles)
};
for handle in &handles {
handle.abort();
}
for handle in handles {
let _ = handle.await;
}
}
async fn run_renderer_watchdog(&self, renderer_id: i32, generation: u64) {
@@ -1585,9 +1645,8 @@ where
// raw parse would classify -1 as `Some(-1)` and fall into the `Some(_)`
// peer-active arm — suppressing the idle auto-take. Filtering `>= 0` maps
// -1 (and any negative sentinel) to None, the genuine "session idle" state.
let incoming_active = normalize_active_renderer_id(
payload.get("active_renderer_id").and_then(Value::as_i64),
);
let incoming_active =
normalize_active_renderer_id(payload.get("active_renderer_id").and_then(Value::as_i64));
let server = match incoming_active {
None => ServerActiveState::None,
Some(id) if Some(id) == local_id => ServerActiveState::Me,
@@ -1743,10 +1802,33 @@ where
/// teardown, bootstrap, renderer-join, raw error) go through `host` so both
/// frontends share this control flow.
pub async fn run_session_loop(
&self,
host: Arc<dyn SessionLoopHost>,
transport_rx: tokio::sync::broadcast::Receiver<TransportEvent>,
idle_retry_active: bool,
) {
self.run_session_loop_with_prefetched(
host,
transport_rx,
idle_retry_active,
VecDeque::new(),
)
.await;
}
/// Run the shared transport loop after draining events that this exact
/// receiver already consumed during a transactional preflight.
///
/// `prefetched` is processed once, in FIFO order, before the receiver is
/// polled again. Callers must move (not copy) consumed events into this
/// queue; the receiver then resumes at its existing cursor, so the handoff
/// has neither a loss window nor a replay duplicate.
pub async fn run_session_loop_with_prefetched(
&self,
host: Arc<dyn SessionLoopHost>,
mut transport_rx: tokio::sync::broadcast::Receiver<TransportEvent>,
idle_retry_active: bool,
mut prefetched: VecDeque<TransportEvent>,
) {
// NOTE: `transport_rx` is subscribed by the adapter SYNCHRONOUSLY before
// this task is spawned (and before any further await), so the receiver is
@@ -1772,7 +1854,11 @@ where
// until the session_uuid is confirmed. Reset on disconnect.
let mut lagged_reask_attempts: u32 = 0;
loop {
match transport_rx.recv().await {
let next_event = match prefetched.pop_front() {
Some(event) => Ok(event),
None => transport_rx.recv().await,
};
match next_event {
Ok(event) => {
// P1-3: on every SESSION_STATE, capture our prior active/playing
// state + classify the server's active renderer BEFORE the sink
@@ -1781,8 +1867,10 @@ where
let mut takeover_input: Option<SessionStateTakeoverInput> = None;
if let TransportEvent::InboundQueueServerEvent(ref evt) = event {
if evt.message_type() == "MESSAGE_TYPE_SRVR_CTRL_SESSION_STATE" {
takeover_input =
Some(self.capture_session_state_takeover_input(&evt.payload).await);
takeover_input = Some(
self.capture_session_state_takeover_input(&evt.payload)
.await,
);
if !renderer_joined {
if let Some(session_uuid) =
evt.payload.get("session_uuid").and_then(|v| v.as_str())
@@ -1794,7 +1882,9 @@ where
)
.await;
} else {
log::warn!("[QConnect] SESSION_STATE received but no session_uuid in payload: {}", evt.payload);
log::warn!(
"[QConnect] SESSION_STATE received without session_uuid"
);
}
}
}
@@ -1914,16 +2004,10 @@ where
message
);
}
TransportEvent::CloudError {
msg_id,
code,
descr,
} => {
TransportEvent::CloudError { msg_id, code } => {
log::warn!(
"[QConnect/Transport] Cloud rejected session: msg_id={} code={} descr={:?} (issue #358)",
msg_id,
code,
descr
"[QConnect/Transport] Cloud rejected session: msg_id={} code={} (issue #358)",
msg_id, code
);
self.sink
.on_event(QconnectAppEvent::Diagnostic {
@@ -1932,7 +2016,7 @@ where
payload: serde_json::json!({
"msg_id": msg_id,
"code": code,
"descr": descr,
"category": "cloud-rejected",
}),
})
.await;
@@ -2105,20 +2189,23 @@ where
#[cfg(test)]
mod tests {
use std::collections::VecDeque;
use std::sync::Arc;
use async_trait::async_trait;
use qconnect_core::QueueVersion;
use qconnect_transport_ws::InMemoryWsTransport;
use crate::session::{LocalIdentity, QconnectRendererInfo, ServerActiveState};
use crate::session::{
LocalIdentity, QconnectLifecycleState, QconnectRendererInfo, ServerActiveState,
};
use crate::QconnectRemoteSyncState;
use serde_json::json;
use tokio::sync::Mutex;
use crate::{QconnectAppEvent, QconnectEventSink};
use super::{queue_hashes_diverge, QconnectApp};
use super::{queue_hashes_diverge, QconnectApp, SessionLoopHost};
use qconnect_core::QConnectQueueState;
use qconnect_protocol::{
QueueCommandType, QueueEventType, QueueServerEvent, RendererCommandType,
@@ -2144,6 +2231,33 @@ mod tests {
}
}
#[derive(Default)]
struct TestSessionLoopHost {
lifecycles: Mutex<Vec<QconnectLifecycleState>>,
}
#[async_trait]
impl SessionLoopHost for TestSessionLoopHost {
async fn update_lifecycle(&self, state: QconnectLifecycleState) {
self.lifecycles.lock().await.push(state);
}
async fn bootstrap_after_reconnect(&self) {}
async fn deferred_renderer_join(&self, _session_uuid: String, _reason: i32) {}
async fn on_reconnect_exhausted(
&self,
_attempts: u32,
_last_reason: String,
_idle_retry_active: bool,
) -> bool {
true
}
async fn on_loop_error(&self, _message: String) {}
}
fn test_config() -> WsTransportConfig {
let mut config = WsTransportConfig::default();
config.endpoint_url = "wss://example.invalid/ws".to_string();
@@ -2169,6 +2283,66 @@ mod tests {
(app, sink, transport, events_rx)
}
#[tokio::test]
async fn session_loop_drains_prefetched_events_once_before_live_receiver() {
// Build without `app.connect()`: that helper intentionally emits its own
// TransportConnected sink event, which is outside the prefetched/live
// receiver sequence this test measures.
let transport = Arc::new(InMemoryWsTransport::new());
let sink = TestSink::default();
let app = QconnectApp::new(
transport,
Arc::new(sink.clone()),
Arc::new(Mutex::new(QconnectRemoteSyncState::default())),
);
let (events_tx, mut events_rx) = tokio::sync::broadcast::channel(8);
events_tx
.send(qconnect_transport_ws::TransportEvent::Connected)
.expect("preflight receiver exists");
events_tx
.send(qconnect_transport_ws::TransportEvent::SessionEstablished)
.expect("preflight receiver exists");
let prefetched = VecDeque::from([
events_rx.recv().await.expect("prefetched connected event"),
events_rx
.recv()
.await
.expect("prefetched established event"),
]);
events_tx
.send(qconnect_transport_ws::TransportEvent::Disconnected)
.expect("live receiver exists");
drop(events_tx);
let host = Arc::new(TestSessionLoopHost::default());
let loop_host: Arc<dyn SessionLoopHost> = host.clone();
app.run_session_loop_with_prefetched(loop_host, events_rx, false, prefetched)
.await;
assert_eq!(
*host.lifecycles.lock().await,
vec![
QconnectLifecycleState::Connected,
QconnectLifecycleState::Reconnecting,
]
);
let sink_events = sink.snapshot().await;
assert_eq!(
sink_events
.iter()
.filter(|event| matches!(event, QconnectAppEvent::TransportConnected))
.count(),
1
);
assert_eq!(
sink_events
.iter()
.filter(|event| matches!(event, QconnectAppEvent::TransportDisconnected))
.count(),
1
);
}
fn renderer_info(renderer_id: i32, device_uuid: &str) -> QconnectRendererInfo {
QconnectRendererInfo {
renderer_id,
@@ -2194,7 +2368,10 @@ mod tests {
let mut state = handle.lock().await;
state.session.local_renderer_id = Some(1);
state.session.active_renderer_id = Some(2);
state.session.renderers = vec![renderer_info(1, "local-uuid"), renderer_info(2, "peer-uuid")];
state.session.renderers = vec![
renderer_info(1, "local-uuid"),
renderer_info(2, "peer-uuid"),
];
}
/// SESSION_STATE writes session topology under the sync lock and reports the
@@ -2356,6 +2533,35 @@ mod tests {
);
}
#[tokio::test(start_paused = true)]
async fn disconnect_aborts_runtime_owned_watchdog() {
let (app, sink, _transport, _rx) = build_connected_app().await;
seed_local_and_active_peer(&app).await;
let armed = app
.apply_session_management_event(
"MESSAGE_TYPE_SRVR_CTRL_RENDERER_STATE_UPDATED",
&json!({ "renderer_id": 2, "player_state": { "playing_state": 2 } }),
&local_identity("local-uuid"),
)
.await;
let (renderer_id, generation) = armed.watchdog_arm.expect("arm");
app.arm_renderer_watchdog(renderer_id, generation);
app.disconnect().await.expect("disconnect");
tokio::task::yield_now().await;
tokio::time::advance(std::time::Duration::from_millis(12_010)).await;
tokio::task::yield_now().await;
assert!(
!sink
.snapshot()
.await
.iter()
.any(|event| matches!(event, QconnectAppEvent::RendererUnreachable { .. })),
"a retired app watchdog must not retain or publish through its sink"
);
}
/// ACTIVE_DISCONNECTED(status==2) for the active peer reports the
/// disconnected renderer; freezing emits RendererDisconnected + UNKNOWN(0).
#[tokio::test]
@@ -2427,6 +2633,42 @@ mod tests {
);
}
#[tokio::test(start_paused = true)]
async fn disconnect_aborts_pending_timeout_and_resync() {
let (app, sink, transport, _events_rx) = build_connected_app().await;
let command = app
.build_queue_command(
QueueCommandType::CtrlSrvrQueueAddTracks,
json!({"track_ids":[101]}),
)
.await;
app.send_queue_command(command)
.await
.expect("send queue command");
let sent_before_disconnect = transport.sent_messages().await.len();
app.disconnect().await.expect("disconnect");
tokio::task::yield_now().await;
tokio::time::advance(std::time::Duration::from_millis(
QconnectApp::<InMemoryWsTransport, TestSink>::PENDING_ACTION_TIMEOUT_MS + 10,
))
.await;
tokio::task::yield_now().await;
let events = sink.snapshot().await;
assert!(!events
.iter()
.any(|event| matches!(event, QconnectAppEvent::PendingActionTimedOut { .. })));
assert!(!events
.iter()
.any(|event| matches!(event, QconnectAppEvent::QueueResyncTriggered)));
assert_eq!(
transport.sent_messages().await.len(),
sent_before_disconnect,
"retired timeout must not send ask-for-state"
);
}
#[tokio::test]
async fn concurrent_remote_event_cancels_pending_and_requests_resync() {
let (app, sink, transport, _events_rx) = build_connected_app().await;
File diff suppressed because it is too large. Load diff
+69 -9
View File
@@ -12,7 +12,7 @@ use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
use futures_util::FutureExt;
use futures_util::{stream::FuturesUnordered, FutureExt, StreamExt};
use serde::{Deserialize, Serialize};
use tokio::sync::{watch, Mutex};
use tokio::task::JoinHandle;
@@ -426,9 +426,18 @@ impl<H: DelegationHost> DelegationCoordinator<H> {
/// Declare that the already-prepared owner authority is ready. Intended for
/// initial global-QConnect startup after owner authentication.
pub async fn declare_owner_ready(&self) -> bool {
self.declare_owner_ready_if(|| true).await
}
/// Declare owner readiness only while an external lifecycle intent remains
/// current. The predicate runs inside the coordinator commit gate and must
/// be synchronous and bounded. This closes disable-vs-connect races without
/// making teardown wait behind owner network preparation.
pub async fn declare_owner_ready_if(&self, is_current: impl FnOnce() -> bool) -> bool {
let _gate = self.inner.commit_gate.lock().await;
let mut state = self.inner.state.lock().await;
if state.lifecycle != CoordinatorLifecycle::Disabled
if !is_current()
|| state.lifecycle != CoordinatorLifecycle::Disabled
|| state.pending.is_some()
|| !matches!(state.active, ActiveControl::Owner)
{
@@ -523,9 +532,7 @@ impl<H: DelegationHost> DelegationCoordinator<H> {
let mut tasks = recover_std_lock(&self.inner.tasks);
tasks.drain().map(|(_, task)| task).collect::<Vec<_>>()
};
for task in tasks {
await_or_abort(task, self.inner.config.cleanup_timeout).await;
}
await_all_or_abort(tasks, self.inner.config.cleanup_timeout).await;
let shutdown_timed_out = tokio::time::timeout(
self.inner.config.cleanup_timeout,
self.inner.host.shutdown_authority(),
@@ -1108,11 +1115,30 @@ fn recover_std_lock<T>(mutex: &std::sync::Mutex<T>) -> std::sync::MutexGuard<'_,
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
async fn await_or_abort(mut task: JoinHandle<()>, timeout: Duration) {
if tokio::time::timeout(timeout, &mut task).await.is_err() {
task.abort();
let _ = task.await;
/// Give every candidate/restore transaction one shared graceful-cleanup
/// budget, then abort the remainder together. A hostile burst must not turn a
/// nominal two-second shutdown budget into two seconds per generation.
async fn await_all_or_abort(tasks: Vec<JoinHandle<()>>, timeout: Duration) {
let mut tasks = tasks.into_iter().collect::<FuturesUnordered<_>>();
let timed_out = tokio::time::timeout(timeout, async {
while tasks.next().await.is_some() {}
})
.await
.is_err();
if !timed_out {
return;
}
for task in tasks.iter() {
task.abort();
}
// Aborted Tokio tasks normally join on the next poll. Keep even this tail
// bounded: cancellation-safe host cleanup and authority stamps own the
// late-resource safety if a task refuses to yield promptly.
let _ = tokio::time::timeout(Duration::from_millis(250), async {
while tasks.next().await.is_some() {}
})
.await;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -1155,6 +1181,25 @@ mod tests {
use super::*;
#[tokio::test(start_paused = true)]
async fn candidate_cleanup_uses_one_shared_shutdown_budget() {
let tasks = (0..3)
.map(|_| tokio::spawn(std::future::pending::<()>()))
.collect();
let cleanup = tokio::spawn(await_all_or_abort(tasks, Duration::from_secs(2)));
tokio::task::yield_now().await;
assert!(!cleanup.is_finished());
tokio::time::advance(Duration::from_secs(2)).await;
tokio::task::yield_now().await;
assert!(
cleanup.is_finished(),
"cleanup multiplied its timeout by the number of candidate tasks"
);
cleanup.await.expect("cleanup task panicked");
}
#[derive(Clone, Copy)]
struct FakeCandidate {
id: u64,
@@ -1683,6 +1728,21 @@ mod tests {
coordinator.shutdown().await;
}
#[tokio::test]
async fn declare_owner_ready_revalidates_external_intent_inside_commit_gate() {
let host = Arc::new(FakeHost::default());
let coordinator = DelegationCoordinator::disabled(
Arc::clone(&host),
DelegationCoordinatorConfig::default(),
);
assert!(!coordinator.declare_owner_ready_if(|| false).await);
assert_eq!(coordinator.snapshot().phase, DelegationPhase::Disabled);
assert!(coordinator.declare_owner_ready_if(|| true).await);
assert_eq!(coordinator.snapshot().phase, DelegationPhase::OwnerReady);
coordinator.shutdown().await;
}
#[tokio::test]
async fn published_snapshot_revisions_are_strictly_monotonic() {
let host = Arc::new(FakeHost::default());
+40
View File
@@ -3,6 +3,46 @@ use qconnect_protocol::ProtocolError;
use qconnect_transport_ws::WsTransportError;
use thiserror::Error;
/// Stable, payload-free diagnostics for owner-authority failures that cross
/// the QConnect renderer boundary.
///
/// Owner API and playback errors can contain signed URLs, credential-bearing routing
/// material, or server-controlled response text. Frontend adapters must first
/// classify those errors into this enum and may only format this value.
#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
pub enum QconnectOwnerFailure {
#[error("owner service is unavailable")]
Unavailable,
#[error("owner network request failed")]
Network,
#[error("owner authentication failed")]
Authentication,
#[error("owner authorization failed")]
Authorization,
#[error("owner service is offline")]
Offline,
#[error("owner request was rate limited")]
RateLimited,
#[error("owner server request failed")]
Server,
#[error("owner service returned an invalid response")]
InvalidResponse,
#[error("owner track is unavailable")]
TrackUnavailable,
#[error("owner playback failed")]
Playback,
#[error("owner internal operation failed")]
Internal,
}
impl QconnectOwnerFailure {
/// Collapse a legacy playback `String` without inspecting or retaining it.
/// The player layer predates typed errors and may have embedded a signed URL.
pub const fn from_opaque_playback_error(_error: &str) -> Self {
Self::Playback
}
}
#[derive(Debug, Error)]
pub enum QconnectAppError {
#[error(transparent)]
+20 -16
View File
@@ -3,6 +3,7 @@
//! Application adapter that composes qconnect core + protocol + transport.
mod app;
mod authority;
mod delegation;
mod error;
mod events;
@@ -12,35 +13,26 @@ pub mod renderer;
mod renderer_engine;
mod reporting;
pub mod session;
mod state;
pub mod startup;
mod state;
mod sync_state;
pub use app::{
queue_payload_track_preview, QconnectApp, SessionApplyOutcome, SessionLoopHost,
SessionStateTakeoverInput,
};
pub use startup::{compute_effective_startup, QconnectStartupMode};
pub use authority::{
AuthorityActionPermit, AuthorityCell, AuthorityOrigin, AuthorityStamp,
ExactOwnerAuthorityObservation, OwnerAuthorityObservation, OwnerAuthorityToken,
QconnectDisabledToken, QconnectEnableIntent, QconnectEnableToken,
};
pub use delegation::{
CommitRejected, CredentialOrigin, DelegationCancellation, DelegationCandidate,
DelegationCoordinator, DelegationCoordinatorConfig, DelegationCoordinatorError,
DelegationErrorCode, DelegationHost, DelegationPhase, DelegationSnapshot, RestoreReason,
};
pub use error::QconnectAppError;
pub use error::{QconnectAppError, QconnectOwnerFailure};
pub use events::{NoOpEventSink, QconnectAppEvent, QconnectEventSink};
pub use session::{
build_effective_renderer_snapshot, build_session_renderer_snapshot, compute_connection_state,
deferred_join_reason,
find_unique_renderer_id, is_local_renderer_active, is_peer_renderer_active,
normalize_active_renderer_id, quality_from_max_audio_quality, queue_item_snapshot_for_cursor,
qconnect_millis_from_secs,
refresh_local_renderer_id, renderer_allows_remote_volume, should_arm_renderer_watchdog,
should_reask_queue_state, ConnectionDecision, LocalIdentity, QconnectFileAudioQualitySnapshot,
QconnectLifecycleState, QconnectRendererInfo, QconnectSessionRendererState,
QconnectSessionState, RendererStatus, ServerActiveState,
JOIN_SESSION_REASON_CONTROLLER_REQUEST, JOIN_SESSION_REASON_RECONNECTION,
QCONNECT_RENDERER_LOST_TIMEOUT_MS,
};
pub use feature_flags::{
QBZ_QCONNECT_PANEL_SWITCH, QBZ_QCONNECT_QUEUE_MODEL, QBZ_QCONNECT_STRICT_DOMAIN_ISOLATION,
QBZ_QCONNECT_TRANSPORT,
@@ -58,6 +50,18 @@ pub use reporting::{
build_renderer_playback_report, qconnect_report_track_id, renderer_buffer_state,
RendererPlaybackSnapshot,
};
pub use session::{
build_effective_renderer_snapshot, build_session_renderer_snapshot, compute_connection_state,
deferred_join_reason, find_unique_renderer_id, is_local_renderer_active,
is_peer_renderer_active, normalize_active_renderer_id, qconnect_millis_from_secs,
quality_from_max_audio_quality, queue_item_snapshot_for_cursor, refresh_local_renderer_id,
renderer_allows_remote_volume, should_arm_renderer_watchdog, should_reask_queue_state,
ConnectionDecision, LocalIdentity, QconnectFileAudioQualitySnapshot, QconnectLifecycleState,
QconnectRendererInfo, QconnectSessionRendererState, QconnectSessionState, RendererStatus,
ServerActiveState, JOIN_SESSION_REASON_CONTROLLER_REQUEST, JOIN_SESSION_REASON_RECONNECTION,
QCONNECT_RENDERER_LOST_TIMEOUT_MS,
};
pub use startup::{compute_effective_startup, QconnectStartupMode};
pub use state::QconnectRuntimeState;
pub use sync_state::{
cache_renderer_snapshot, ensure_session_renderer_state, sync_session_renderer_active_flags,