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,
|
sync_session_renderer_active_flags,
|
||||||
};
|
};
|
||||||
use super::{
|
use super::{
|
||||||
qconnect_now_ms, QconnectRemoteSyncState, QconnectRendererInfo, RendererStatus,
|
qconnect_now_ms, should_arm_renderer_watchdog, QconnectRemoteSyncState, QconnectRendererInfo,
|
||||||
PLAYING_STATE_UNKNOWN,
|
RendererStatus, PLAYING_STATE_PLAYING, PLAYING_STATE_UNKNOWN, QCONNECT_RENDERER_LOST_TIMEOUT_MS,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -125,6 +125,7 @@ impl TauriQconnectEventSink {
|
|||||||
let mut sync_local_playback = false;
|
let mut sync_local_playback = false;
|
||||||
let mut apply_loop_mode: Option<i32> = None;
|
let mut apply_loop_mode: Option<i32> = None;
|
||||||
let mut disconnected_renderer_id: 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;
|
let mut state = self.sync_state.lock().await;
|
||||||
match message_type {
|
match message_type {
|
||||||
"MESSAGE_TYPE_SRVR_CTRL_SESSION_STATE" => {
|
"MESSAGE_TYPE_SRVR_CTRL_SESSION_STATE" => {
|
||||||
@@ -267,6 +268,8 @@ impl TauriQconnectEventSink {
|
|||||||
sync_session_renderer_active_flags(&mut state);
|
sync_session_renderer_active_flags(&mut state);
|
||||||
remote_projection_renderer_id = state.session.active_renderer_id;
|
remote_projection_renderer_id = state.session.active_renderer_id;
|
||||||
sync_local_playback = true;
|
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" => {
|
"MESSAGE_TYPE_SRVR_CTRL_RENDERER_STATE_UPDATED" => {
|
||||||
let Some(renderer_id) = payload.get("renderer_id").and_then(Value::as_i64) else {
|
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();
|
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);
|
remote_projection_renderer_id = Some(renderer_id as i32);
|
||||||
sync_local_playback = true;
|
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
|
// P0-2: graceful disconnect. status==ACTIVE_DISCONNECTED(2) for
|
||||||
// the active remote renderer (id != -1) means the renderer left
|
// the active remote renderer (id != -1) means the renderer left
|
||||||
// cleanly — freeze the projection so the UI stops lying.
|
// cleanly — freeze the projection so the UI stops lying.
|
||||||
let status =
|
let status =
|
||||||
RendererStatus::from_wire(payload.get("status").and_then(Value::as_i64));
|
RendererStatus::from_wire(payload.get("status").and_then(Value::as_i64));
|
||||||
if status == RendererStatus::ActiveDisconnected
|
if status == RendererStatus::ActiveDisconnected && is_active_peer {
|
||||||
&& renderer_id != -1
|
|
||||||
&& state.session.active_renderer_id == Some(renderer_id as i32)
|
|
||||||
&& is_peer_renderer_active(&state.session)
|
|
||||||
{
|
|
||||||
disconnected_renderer_id = Some(renderer_id as i32);
|
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" => {
|
"MESSAGE_TYPE_SRVR_CTRL_VOLUME_CHANGED" => {
|
||||||
let Some(renderer_id) = payload.get("renderer_id").and_then(Value::as_i64) else {
|
let Some(renderer_id) = payload.get("renderer_id").and_then(Value::as_i64) else {
|
||||||
@@ -404,6 +420,50 @@ impl TauriQconnectEventSink {
|
|||||||
)
|
)
|
||||||
.await;
|
.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
|
/// 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 PLAYING_STATE_PAUSED: i32 = 3;
|
||||||
pub(super) const BUFFER_STATE_OK: i32 = 2;
|
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)
|
// 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_UNKNOWN: i32 = 0;
|
||||||
pub(super) const AUDIO_QUALITY_MP3: i32 = 1;
|
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
|
/// redundant reloads when an echo SetState arrives during the
|
||||||
/// in-progress buffer/decode window of a previously triggered load.
|
/// in-progress buffer/decode window of a previously triggered load.
|
||||||
pub(super) last_load_attempt: Option<(u64, std::time::Instant)>,
|
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 {
|
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(Some(99)), RendererStatus::Inactive); // UNRECOGNIZED
|
||||||
assert_eq!(RendererStatus::from_wire(None), RendererStatus::Inactive); // absent field
|
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));
|
||||||
|
}
|
||||||
@@ -1840,6 +1840,7 @@
|
|||||||
"remoteQueuedLater": "Zur Qobuz Connect-Warteschlange hinzugefügt.",
|
"remoteQueuedLater": "Zur Qobuz Connect-Warteschlange hinzugefügt.",
|
||||||
"remoteQueueFailed": "Aktualisierung der Qobuz Connect-Warteschlange fehlgeschlagen.",
|
"remoteQueueFailed": "Aktualisierung der Qobuz Connect-Warteschlange fehlgeschlagen.",
|
||||||
"rendererDisconnected": "Das entfernte Gerät hat die Verbindung zu Qobuz Connect getrennt.",
|
"rendererDisconnected": "Das entfernte Gerät hat die Verbindung zu Qobuz Connect getrennt.",
|
||||||
|
"rendererUnreachable": "Das entfernte Gerät reagiert nicht mehr.",
|
||||||
"sessionTitle": "Sitzung",
|
"sessionTitle": "Sitzung",
|
||||||
"sessionUuid": "Sitzungs-UUID",
|
"sessionUuid": "Sitzungs-UUID",
|
||||||
"activeRenderer": "Aktiver Renderer",
|
"activeRenderer": "Aktiver Renderer",
|
||||||
|
|||||||
@@ -1840,6 +1840,7 @@
|
|||||||
"remoteQueuedLater": "Added to Qobuz Connect queue.",
|
"remoteQueuedLater": "Added to Qobuz Connect queue.",
|
||||||
"remoteQueueFailed": "Failed to update Qobuz Connect queue.",
|
"remoteQueueFailed": "Failed to update Qobuz Connect queue.",
|
||||||
"rendererDisconnected": "The remote device disconnected from Qobuz Connect.",
|
"rendererDisconnected": "The remote device disconnected from Qobuz Connect.",
|
||||||
|
"rendererUnreachable": "The remote device stopped responding.",
|
||||||
"sessionTitle": "Session",
|
"sessionTitle": "Session",
|
||||||
"sessionUuid": "Session UUID",
|
"sessionUuid": "Session UUID",
|
||||||
"activeRenderer": "Active renderer",
|
"activeRenderer": "Active renderer",
|
||||||
|
|||||||
@@ -1840,6 +1840,7 @@
|
|||||||
"remoteQueuedLater": "Agregada a la cola de Qobuz Connect.",
|
"remoteQueuedLater": "Agregada a la cola de Qobuz Connect.",
|
||||||
"remoteQueueFailed": "No se pudo actualizar la cola de Qobuz Connect.",
|
"remoteQueueFailed": "No se pudo actualizar la cola de Qobuz Connect.",
|
||||||
"rendererDisconnected": "El dispositivo remoto se desconectó de Qobuz Connect.",
|
"rendererDisconnected": "El dispositivo remoto se desconectó de Qobuz Connect.",
|
||||||
|
"rendererUnreachable": "El dispositivo remoto dejó de responder.",
|
||||||
"sessionTitle": "Sesion",
|
"sessionTitle": "Sesion",
|
||||||
"sessionUuid": "UUID de sesion",
|
"sessionUuid": "UUID de sesion",
|
||||||
"activeRenderer": "Renderer activo",
|
"activeRenderer": "Renderer activo",
|
||||||
|
|||||||
@@ -1840,6 +1840,7 @@
|
|||||||
"remoteQueuedLater": "Ajouté à la file Qobuz Connect.",
|
"remoteQueuedLater": "Ajouté à la file Qobuz Connect.",
|
||||||
"remoteQueueFailed": "Échec de la mise à jour de la file Qobuz Connect.",
|
"remoteQueueFailed": "Échec de la mise à jour de la file Qobuz Connect.",
|
||||||
"rendererDisconnected": "L'appareil distant s'est déconnecté de Qobuz Connect.",
|
"rendererDisconnected": "L'appareil distant s'est déconnecté de Qobuz Connect.",
|
||||||
|
"rendererUnreachable": "L'appareil distant ne répond plus.",
|
||||||
"sessionTitle": "Session",
|
"sessionTitle": "Session",
|
||||||
"sessionUuid": "UUID de session",
|
"sessionUuid": "UUID de session",
|
||||||
"activeRenderer": "Rendu actif",
|
"activeRenderer": "Rendu actif",
|
||||||
|
|||||||
@@ -1840,6 +1840,7 @@
|
|||||||
"remoteQueuedLater": "Adicionado à fila do Qobuz Connect.",
|
"remoteQueuedLater": "Adicionado à fila do Qobuz Connect.",
|
||||||
"remoteQueueFailed": "Falha ao atualizar a fila do Qobuz Connect.",
|
"remoteQueueFailed": "Falha ao atualizar a fila do Qobuz Connect.",
|
||||||
"rendererDisconnected": "O dispositivo remoto desconectou-se do Qobuz Connect.",
|
"rendererDisconnected": "O dispositivo remoto desconectou-se do Qobuz Connect.",
|
||||||
|
"rendererUnreachable": "O dispositivo remoto parou de responder.",
|
||||||
"sessionTitle": "Sessão",
|
"sessionTitle": "Sessão",
|
||||||
"sessionUuid": "UUID da sessão",
|
"sessionUuid": "UUID da sessão",
|
||||||
"activeRenderer": "Renderer ativo",
|
"activeRenderer": "Renderer ativo",
|
||||||
|
|||||||
@@ -5593,6 +5593,7 @@
|
|||||||
let unlistenQconnectStatusChanged: UnlistenFn | null = null;
|
let unlistenQconnectStatusChanged: UnlistenFn | null = null;
|
||||||
let unlistenQconnectAdmissionBlocked: UnlistenFn | null = null;
|
let unlistenQconnectAdmissionBlocked: UnlistenFn | null = null;
|
||||||
let unlistenQconnectRendererDisconnected: UnlistenFn | null = null;
|
let unlistenQconnectRendererDisconnected: UnlistenFn | null = null;
|
||||||
|
let unlistenQconnectRendererUnreachable: UnlistenFn | null = null;
|
||||||
let unlistenQconnectDiagnostic: UnlistenFn | null = null;
|
let unlistenQconnectDiagnostic: UnlistenFn | null = null;
|
||||||
let unlistenQconnectRendererReportDebug: UnlistenFn | null = null;
|
let unlistenQconnectRendererReportDebug: UnlistenFn | null = null;
|
||||||
let unlistenAudioDeviceMissing: UnlistenFn | null = null;
|
let unlistenAudioDeviceMissing: UnlistenFn | null = null;
|
||||||
@@ -5843,6 +5844,17 @@
|
|||||||
if (disposed) { unlistenRendererDisconnected(); return; }
|
if (disposed) { unlistenRendererDisconnected(); return; }
|
||||||
unlistenQconnectRendererDisconnected = unlistenRendererDisconnected;
|
unlistenQconnectRendererDisconnected = unlistenRendererDisconnected;
|
||||||
|
|
||||||
|
const unlistenRendererUnreachable = await listen<{ renderer_id: number }>(
|
||||||
|
'qconnect:renderer_unreachable',
|
||||||
|
(event) => {
|
||||||
|
pushQobuzConnectDiagnostic('qconnect:renderer_unreachable', 'warn', event.payload);
|
||||||
|
void refreshQobuzConnectRuntimeState();
|
||||||
|
showToast($t('qconnect.rendererUnreachable'), 'error');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (disposed) { unlistenRendererUnreachable(); return; }
|
||||||
|
unlistenQconnectRendererUnreachable = unlistenRendererUnreachable;
|
||||||
|
|
||||||
const unlisten9 = await listen<QconnectDiagnosticsPayload>('qconnect:diagnostic', (event) => {
|
const unlisten9 = await listen<QconnectDiagnosticsPayload>('qconnect:diagnostic', (event) => {
|
||||||
pushQobuzConnectDiagnostic(
|
pushQobuzConnectDiagnostic(
|
||||||
event.payload.channel,
|
event.payload.channel,
|
||||||
@@ -5909,6 +5921,7 @@
|
|||||||
unlistenQconnectStatusChanged?.();
|
unlistenQconnectStatusChanged?.();
|
||||||
unlistenQconnectAdmissionBlocked?.();
|
unlistenQconnectAdmissionBlocked?.();
|
||||||
unlistenQconnectRendererDisconnected?.();
|
unlistenQconnectRendererDisconnected?.();
|
||||||
|
unlistenQconnectRendererUnreachable?.();
|
||||||
unlistenQconnectDiagnostic?.();
|
unlistenQconnectDiagnostic?.();
|
||||||
unlistenQconnectRendererReportDebug?.();
|
unlistenQconnectRendererReportDebug?.();
|
||||||
unlistenAudioDeviceMissing?.();
|
unlistenAudioDeviceMissing?.();
|
||||||
|
|||||||
Reference in new issue
Block a user