fix(qconnect): enforce shared lifecycle authority
cx
This commit is contained in:
22 files changed
+3233
-406
No files matched your search
+268
-26
@@ -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
@@ -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());
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user