From f6506cd4ea7604010ca5e739f011064f50151dff Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 22 Feb 2026 15:58:56 -0600 Subject: [PATCH] fix: break infinite feedback loop in renderer state reports The server echoes every RNDR_SRVR_STATE_UPDATED as a SET_STATE command with only next_track updated (playing_state=null, current_track=null). send_renderer_reports was replying to these echo SET_STATE commands with another state report, creating an infinite loop that generated 47k log lines (8.2MB) in seconds and completely blocked playback. Fix: only send state reports for substantive SET_STATE commands that carry playing_state or current_track changes. Echo commands (next_track only) are silently consumed. Also demote diagnostic logging from info to debug to reduce terminal noise during normal operation. --- crates/qconnect-app/src/app.rs | 77 ++++++++++++++------------ crates/qconnect-protocol/src/mapper.rs | 2 +- src-tauri/src/qconnect_service.rs | 2 +- 3 files changed, 45 insertions(+), 36 deletions(-) diff --git a/crates/qconnect-app/src/app.rs b/crates/qconnect-app/src/app.rs index 5f6efc7c4..bda5426d9 100644 --- a/crates/qconnect-app/src/app.rs +++ b/crates/qconnect-app/src/app.rs @@ -415,40 +415,49 @@ where queue_version_ref: qconnect_core::QueueVersion, ) -> Result<(), QconnectAppError> { match command { - RendererCommand::SetState { .. } => { - log::info!( - "[QConnect/Report] SetState report: playing={:?} pos={:?} track={:?} next={:?} qv={}.{}", - renderer.playing_state, - renderer.current_position_ms, - renderer.current_track.as_ref().map(|t| (t.track_id, t.queue_item_id)), - renderer.next_track.as_ref().map(|t| (t.track_id, t.queue_item_id)), - queue_version_ref.major, - queue_version_ref.minor - ); - let report = RendererReport::new( - RendererReportType::RndrSrvrStateUpdated, - self.next_action_uuid(), - queue_version_ref, - // NOTE: We intentionally OMIT current_queue_item_id and next_queue_item_id. - // The server validates these IDs against its queue state and rejects reports - // when IDs don't match ("Current track not found in queue nor autoplay"). - // This happens after QBZ-initiated queue loads where the server assigns - // non-standard queue_item_ids (first track = track_id, rest = 1..N). - // The server already knows the current track from SET_STATE commands. - serde_json::json!({ - "playing_state": renderer.playing_state, - "buffer_state": infer_buffer_state(renderer.playing_state), - "current_position": renderer.current_position_ms, - "duration": Option::::None, - "queue_version": { - "major": queue_version_ref.major, - "minor": queue_version_ref.minor - }, - "current_queue_item_id": Option::::None, - "next_queue_item_id": Option::::None - }), - ); - self.send_renderer_report(report).await?; + RendererCommand::SetState { + playing_state, + current_track, + .. + } => { + // Only send a state report when the SET_STATE carries a meaningful + // change (playing_state or current_track). The server echoes every + // state report as a SET_STATE with only next_track updated, which + // would create an infinite feedback loop if we replied to it. + let is_substantive = playing_state.is_some() || current_track.is_some(); + if is_substantive { + log::info!( + "[QConnect/Report] SetState report: playing={:?} pos={:?} track={:?} next={:?} qv={}.{}", + renderer.playing_state, + renderer.current_position_ms, + renderer.current_track.as_ref().map(|t| (t.track_id, t.queue_item_id)), + renderer.next_track.as_ref().map(|t| (t.track_id, t.queue_item_id)), + queue_version_ref.major, + queue_version_ref.minor + ); + let report = RendererReport::new( + RendererReportType::RndrSrvrStateUpdated, + self.next_action_uuid(), + queue_version_ref, + serde_json::json!({ + "playing_state": renderer.playing_state, + "buffer_state": infer_buffer_state(renderer.playing_state), + "current_position": renderer.current_position_ms, + "duration": Option::::None, + "queue_version": { + "major": queue_version_ref.major, + "minor": queue_version_ref.minor + }, + "current_queue_item_id": Option::::None, + "next_queue_item_id": Option::::None + }), + ); + self.send_renderer_report(report).await?; + } else { + log::debug!( + "[QConnect/Report] Skipping echo SET_STATE report (no playing_state or current_track change)" + ); + } } RendererCommand::SetVolume { volume, .. } => { let resolved_volume = renderer.volume.or(*volume); diff --git a/crates/qconnect-protocol/src/mapper.rs b/crates/qconnect-protocol/src/mapper.rs index 1382877ce..a3c3a8e32 100644 --- a/crates/qconnect-protocol/src/mapper.rs +++ b/crates/qconnect-protocol/src/mapper.rs @@ -432,7 +432,7 @@ fn map_renderer_report(report: &RendererReport) -> Result