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:
vicrodh committed 2026-06-01 03:03:16 -06:00
1 parent d625e0afef
commit a05e8abc25
9 files changed
+122 -7

No files matched your search

+67 -7
View File
@@ -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
+20
View File
@@ -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 {
+17
View File
@@ -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));
}