feat(qconnect): add 12s renderer-liveness watchdog
A playing active-peer renderer that goes silent left the controller UI stuck on stale 'playing' (no equivalent of the webplayer's 12s rendererLost timer). Add a generation-guarded 12s watchdog keyed on the active peer renderer in the RENDERER_STATE_UPDATED handler: reset on every update, arm only while playing_state==PLAYING, disarm on pause/stop/active-change/disconnect. On expiry freeze the projection to UNKNOWN and emit qconnect:renderer_unreachable; Svelte shows an error toast (rendererUnreachable, all 5 locales). Reuses the freeze-projection-to-unknown helper from the ACTIVE_DISCONNECTED path. cc
This commit is contained in:
9 files changed
+122
-7
No files matched your search
@@ -24,8 +24,8 @@ use super::session::{
|
||||
sync_session_renderer_active_flags,
|
||||
};
|
||||
use super::{
|
||||
qconnect_now_ms, QconnectRemoteSyncState, QconnectRendererInfo, RendererStatus,
|
||||
PLAYING_STATE_UNKNOWN,
|
||||
qconnect_now_ms, should_arm_renderer_watchdog, QconnectRemoteSyncState, QconnectRendererInfo,
|
||||
RendererStatus, PLAYING_STATE_PLAYING, PLAYING_STATE_UNKNOWN, QCONNECT_RENDERER_LOST_TIMEOUT_MS,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -125,6 +125,7 @@ impl TauriQconnectEventSink {
|
||||
let mut sync_local_playback = false;
|
||||
let mut apply_loop_mode: Option<i32> = None;
|
||||
let mut disconnected_renderer_id: Option<i32> = None;
|
||||
let mut watchdog_arm: Option<(i32, u64)> = None;
|
||||
let mut state = self.sync_state.lock().await;
|
||||
match message_type {
|
||||
"MESSAGE_TYPE_SRVR_CTRL_SESSION_STATE" => {
|
||||
@@ -267,6 +268,8 @@ impl TauriQconnectEventSink {
|
||||
sync_session_renderer_active_flags(&mut state);
|
||||
remote_projection_renderer_id = state.session.active_renderer_id;
|
||||
sync_local_playback = true;
|
||||
// P0-1: active-renderer change disarms any pending liveness task.
|
||||
Self::bump_watchdog_generation(&mut state);
|
||||
}
|
||||
"MESSAGE_TYPE_SRVR_CTRL_RENDERER_STATE_UPDATED" => {
|
||||
let Some(renderer_id) = payload.get("renderer_id").and_then(Value::as_i64) else {
|
||||
@@ -300,21 +303,34 @@ impl TauriQconnectEventSink {
|
||||
}
|
||||
|
||||
renderer_state.updated_at_ms = qconnect_now_ms();
|
||||
// Snapshot the just-written playing_state for the watchdog decision
|
||||
// (the &mut borrow ends here so we can re-read state.session below).
|
||||
let this_playing_state = renderer_state.playing_state;
|
||||
remote_projection_renderer_id = Some(renderer_id as i32);
|
||||
sync_local_playback = true;
|
||||
|
||||
let is_active_peer = state.session.active_renderer_id
|
||||
== Some(renderer_id as i32)
|
||||
&& is_peer_renderer_active(&state.session)
|
||||
&& renderer_id != -1;
|
||||
|
||||
// P0-2: graceful disconnect. status==ACTIVE_DISCONNECTED(2) for
|
||||
// the active remote renderer (id != -1) means the renderer left
|
||||
// cleanly — freeze the projection so the UI stops lying.
|
||||
let status =
|
||||
RendererStatus::from_wire(payload.get("status").and_then(Value::as_i64));
|
||||
if status == RendererStatus::ActiveDisconnected
|
||||
&& renderer_id != -1
|
||||
&& state.session.active_renderer_id == Some(renderer_id as i32)
|
||||
&& is_peer_renderer_active(&state.session)
|
||||
{
|
||||
if status == RendererStatus::ActiveDisconnected && is_active_peer {
|
||||
disconnected_renderer_id = Some(renderer_id as i32);
|
||||
}
|
||||
|
||||
// P0-1 liveness watchdog. Any RENDERER_STATE_UPDATED for the
|
||||
// active peer resets the timer (bump generation); arm a fresh 12s
|
||||
// task only while PLAYING. A non-playing update disarms (bump
|
||||
// only, no spawn).
|
||||
let new_generation = Self::bump_watchdog_generation(&mut state);
|
||||
if should_arm_renderer_watchdog(this_playing_state, is_active_peer) {
|
||||
watchdog_arm = Some((renderer_id as i32, new_generation));
|
||||
}
|
||||
}
|
||||
"MESSAGE_TYPE_SRVR_CTRL_VOLUME_CHANGED" => {
|
||||
let Some(renderer_id) = payload.get("renderer_id").and_then(Value::as_i64) else {
|
||||
@@ -404,6 +420,50 @@ impl TauriQconnectEventSink {
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// P0-1: arm the generation-guarded 12s liveness watchdog. The spawned
|
||||
// task holds a clone of this sink (TauriQconnectEventSink: Clone). On
|
||||
// wake it re-locks and no-ops unless its captured epoch is still current
|
||||
// AND the renderer is still the active peer AND still nominally playing.
|
||||
if let Some((renderer_id, generation)) = watchdog_arm {
|
||||
let sink = self.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(
|
||||
QCONNECT_RENDERER_LOST_TIMEOUT_MS,
|
||||
))
|
||||
.await;
|
||||
let fire = {
|
||||
let state = sink.sync_state.lock().await;
|
||||
state.watchdog_generation == generation
|
||||
&& state.session.active_renderer_id == Some(renderer_id)
|
||||
&& is_peer_renderer_active(&state.session)
|
||||
&& state
|
||||
.session_renderer_states
|
||||
.get(&renderer_id)
|
||||
.and_then(|r| r.playing_state)
|
||||
== Some(PLAYING_STATE_PLAYING)
|
||||
};
|
||||
if fire {
|
||||
log::warn!(
|
||||
"[QConnect] Renderer {renderer_id} silent for {}ms — marking unreachable",
|
||||
QCONNECT_RENDERER_LOST_TIMEOUT_MS
|
||||
);
|
||||
sink.freeze_active_renderer_projection_to_unknown(
|
||||
renderer_id,
|
||||
"qconnect:renderer_unreachable",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Bump the watchdog epoch, invalidating any in-flight 12s liveness task.
|
||||
/// Returns the new generation (used when arming so the spawned task knows
|
||||
/// which epoch it belongs to).
|
||||
fn bump_watchdog_generation(state: &mut QconnectRemoteSyncState) -> u64 {
|
||||
state.watchdog_generation = state.watchdog_generation.wrapping_add(1);
|
||||
state.watchdog_generation
|
||||
}
|
||||
|
||||
/// Force the active renderer's cached projection to UNKNOWN/stopped and
|
||||
|
||||
@@ -38,6 +38,21 @@ pub(super) const PLAYING_STATE_PLAYING: i32 = 2;
|
||||
pub(super) const PLAYING_STATE_PAUSED: i32 = 3;
|
||||
pub(super) const BUFFER_STATE_OK: i32 = 2;
|
||||
|
||||
/// Official "renderer LOST" silence budget. A *playing* active peer renderer
|
||||
/// that sends no RENDERER_STATE_UPDATED for this long is considered
|
||||
/// unreachable (webplayer arms setTimeout(...,12e3) on onPlayerStateUpdated
|
||||
/// while playingState==PLAY). See 05-sync-status-queue.md §1.
|
||||
pub(super) const QCONNECT_RENDERER_LOST_TIMEOUT_MS: u64 = 12_000;
|
||||
|
||||
/// Pure arming predicate for the renderer-liveness watchdog: arm only while the
|
||||
/// active renderer is a peer AND its reported playing_state is PLAYING.
|
||||
pub(super) fn should_arm_renderer_watchdog(
|
||||
playing_state: Option<i32>,
|
||||
is_active_peer: bool,
|
||||
) -> bool {
|
||||
is_active_peer && playing_state == Some(PLAYING_STATE_PLAYING)
|
||||
}
|
||||
|
||||
// AudioQuality enum: 0=unknown, 1=mp3, 2=cd, 3=hires_l1, 4=hires_l2(192k), 5=hires_l3(384k)
|
||||
pub(super) const AUDIO_QUALITY_UNKNOWN: i32 = 0;
|
||||
pub(super) const AUDIO_QUALITY_MP3: i32 = 1;
|
||||
@@ -74,6 +89,11 @@ pub(super) struct QconnectRemoteSyncState {
|
||||
/// redundant reloads when an echo SetState arrives during the
|
||||
/// in-progress buffer/decode window of a previously triggered load.
|
||||
pub(super) last_load_attempt: Option<(u64, std::time::Instant)>,
|
||||
/// Monotonic epoch for the renderer-liveness watchdog (P0-1). Every armed
|
||||
/// RENDERER_STATE_UPDATED bumps this; a spawned 12s task captures the value
|
||||
/// and no-ops on wake if it was superseded (reset/disarm). Disarm =
|
||||
/// pause/stop/active-change/disconnect, which also bump it.
|
||||
pub(super) watchdog_generation: u64,
|
||||
}
|
||||
|
||||
pub(super) fn qconnect_now_ms() -> u64 {
|
||||
|
||||
@@ -1096,3 +1096,20 @@ fn renderer_status_from_wire_collapses_unknown_and_missing_to_inactive() {
|
||||
assert_eq!(RendererStatus::from_wire(Some(99)), RendererStatus::Inactive); // UNRECOGNIZED
|
||||
assert_eq!(RendererStatus::from_wire(None), RendererStatus::Inactive); // absent field
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watchdog_arms_only_for_playing_active_peer() {
|
||||
use super::{
|
||||
should_arm_renderer_watchdog, PLAYING_STATE_PAUSED, PLAYING_STATE_PLAYING,
|
||||
PLAYING_STATE_STOPPED, PLAYING_STATE_UNKNOWN,
|
||||
};
|
||||
// Arm: playing AND active peer.
|
||||
assert!(should_arm_renderer_watchdog(Some(PLAYING_STATE_PLAYING), true));
|
||||
// Do not arm when paused/stopped/unknown even if active peer.
|
||||
assert!(!should_arm_renderer_watchdog(Some(PLAYING_STATE_PAUSED), true));
|
||||
assert!(!should_arm_renderer_watchdog(Some(PLAYING_STATE_STOPPED), true));
|
||||
assert!(!should_arm_renderer_watchdog(Some(PLAYING_STATE_UNKNOWN), true));
|
||||
assert!(!should_arm_renderer_watchdog(None, true));
|
||||
// Do not arm when not an active peer (e.g. local renderer is active).
|
||||
assert!(!should_arm_renderer_watchdog(Some(PLAYING_STATE_PLAYING), false));
|
||||
}
|
||||
Reference in new issue
Block a user