refactor(qconnect): delegate session-apply + watchdog to qconnect-app; drop Tauri-side duplicate (slice 2+4)

The Tauri event sink's apply_session_management_event is now a thin delegator:
it resolves this device's identity (resolve_local_identity) and calls
QconnectApp::apply_session_management_event, then runs the post-lock CoreBridge
work the returned SessionApplyOutcome asks for in the exact prior order
(loop mode -> local-playback handoff -> projection -> freeze -> watchdog).
Freeze + watchdog route through app.freeze_active_renderer_projection /
app.arm_renderer_watchdog (same shared sync Mutex, same epoch guard).

Deleted from the Tauri adapter (now solely in qconnect-app): the locked
session-management match, bump_watchdog_generation, the inline 12s watchdog
spawn, freeze_active_renderer_projection_to_unknown, and the duplicate
ensure_session_renderer_state / sync_session_renderer_active_flags. ensure_* is
re-exported from qconnect-app for service.rs; refresh_local_renderer_id wrapper
is replaced by resolve_local_identity (returns the injected LocalIdentity), and
the 3 renderer-id-resolution tests call the pure qconnect-app fn with it.

Byte-identity fence holds: src-tauri qconnect 42 green, qconnect-app 38 green.

cc
This commit is contained in:
vicrodh committed 2026-06-02 13:45:33 -06:00
1 parent 77717546fb
commit 776b057b5f
4 files changed
+36 -413

No files matched your search

+24 -367
View File
@@ -24,15 +24,9 @@ use super::corebridge::{
use super::queue_resolution::is_valid_ordered_queue_shuffle_order;
use super::service::emit_qconnect_diagnostic;
use super::session::{
build_session_renderer_snapshot, cache_renderer_snapshot, ensure_session_renderer_state,
is_peer_renderer_active, normalize_active_renderer_id, refresh_local_renderer_id,
sync_session_renderer_active_flags,
};
use super::{
qconnect_now_ms, should_arm_renderer_watchdog, QconnectRemoteSyncState, QconnectRendererInfo,
RendererStatus, BUFFER_STATE_OK, PLAYING_STATE_PLAYING, PLAYING_STATE_UNKNOWN,
QCONNECT_RENDERER_LOST_TIMEOUT_MS,
build_session_renderer_snapshot, cache_renderer_snapshot, is_peer_renderer_active,
};
use super::{QconnectRemoteSyncState, BUFFER_STATE_OK};
/// Concrete `QconnectApp` type used by the Tauri adapter.
type TauriQconnectApp = QconnectApp<NativeWsTransport, TauriQconnectEventSink>;
@@ -207,296 +201,24 @@ impl QconnectEventSink for TauriQconnectEventSink {
}
impl TauriQconnectEventSink {
/// Apply a server session-management event by delegating the locked critical
/// section to qconnect-app, then running the post-lock CoreBridge work the
/// returned `SessionApplyOutcome` asks for (renderer-engine seam stays
/// adapter-side, slice 6). This device's identity is resolved here and
/// injected so qconnect-app stays frontend-agnostic. The post-lock ordering
/// (loop mode -> local-playback handoff -> projection -> freeze -> watchdog)
/// is byte-for-byte the prior inline implementation; only the locked match,
/// watchdog spawn, and freeze now live in qconnect-app.
async fn apply_session_management_event(&self, message_type: &str, payload: &Value) {
let mut remote_projection_renderer_id: Option<i32> = None;
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" => {
if let Some(uuid) = payload.get("session_uuid").and_then(Value::as_str) {
state.session.session_uuid = Some(uuid.to_string());
}
state.session.active_renderer_id = normalize_active_renderer_id(
payload.get("active_renderer_id").and_then(Value::as_i64),
);
if let Some(loop_mode) = payload
.get("loop_mode")
.and_then(Value::as_i64)
.and_then(|value| i32::try_from(value).ok())
{
state.session_loop_mode = Some(loop_mode);
apply_loop_mode = Some(loop_mode);
}
if let (Some(active_renderer_id), Some(loop_mode)) =
(state.session.active_renderer_id, state.session_loop_mode)
{
let renderer_state =
ensure_session_renderer_state(&mut state, active_renderer_id);
renderer_state.loop_mode = Some(loop_mode);
renderer_state.updated_at_ms = qconnect_now_ms();
}
sync_session_renderer_active_flags(&mut state);
sync_local_playback = true;
}
"MESSAGE_TYPE_SRVR_CTRL_ADD_RENDERER" => {
if let Some(renderer_id) = payload.get("renderer_id").and_then(Value::as_i64) {
let renderer_id = renderer_id as i32;
// Don't add duplicates
if !state
.session
.renderers
.iter()
.any(|r| r.renderer_id == renderer_id)
{
let device_info = payload.get("device_info");
state.session.renderers.push(QconnectRendererInfo {
renderer_id,
device_uuid: device_info
.and_then(|d| d.get("device_uuid"))
.and_then(Value::as_str)
.map(String::from),
friendly_name: device_info
.and_then(|d| d.get("friendly_name"))
.and_then(Value::as_str)
.map(String::from),
brand: device_info
.and_then(|d| d.get("brand"))
.and_then(Value::as_str)
.map(String::from),
model: device_info
.and_then(|d| d.get("model"))
.and_then(Value::as_str)
.map(String::from),
device_type: device_info
.and_then(|d| d.get("device_type"))
.and_then(Value::as_i64)
.map(|v| v as i32),
volume_remote_control: device_info
.and_then(|d| d.get("capabilities"))
.and_then(|c| c.get("volume_remote_control"))
.and_then(Value::as_i64)
.map(|v| v as i32),
});
refresh_local_renderer_id(&mut state.session);
}
let _ = ensure_session_renderer_state(&mut state, renderer_id);
sync_session_renderer_active_flags(&mut state);
}
}
"MESSAGE_TYPE_SRVR_CTRL_UPDATE_RENDERER" => {
if let Some(renderer_id) = payload.get("renderer_id").and_then(Value::as_i64) {
let renderer_id = renderer_id as i32;
if let Some(existing) = state
.session
.renderers
.iter_mut()
.find(|r| r.renderer_id == renderer_id)
{
let device_info = payload.get("device_info");
if let Some(device_uuid) = device_info
.and_then(|d| d.get("device_uuid"))
.and_then(Value::as_str)
{
existing.device_uuid = Some(device_uuid.to_string());
}
if let Some(name) = device_info
.and_then(|d| d.get("friendly_name"))
.and_then(Value::as_str)
{
existing.friendly_name = Some(name.to_string());
}
if let Some(brand) = device_info
.and_then(|d| d.get("brand"))
.and_then(Value::as_str)
{
existing.brand = Some(brand.to_string());
}
if let Some(model) = device_info
.and_then(|d| d.get("model"))
.and_then(Value::as_str)
{
existing.model = Some(model.to_string());
}
if let Some(device_type) = device_info
.and_then(|d| d.get("device_type"))
.and_then(Value::as_i64)
{
existing.device_type = Some(device_type as i32);
}
if let Some(vrc) = device_info
.and_then(|d| d.get("capabilities"))
.and_then(|c| c.get("volume_remote_control"))
.and_then(Value::as_i64)
{
existing.volume_remote_control = Some(vrc as i32);
}
refresh_local_renderer_id(&mut state.session);
}
let _ = ensure_session_renderer_state(&mut state, renderer_id);
sync_session_renderer_active_flags(&mut state);
}
}
"MESSAGE_TYPE_SRVR_CTRL_REMOVE_RENDERER" => {
if let Some(renderer_id) = payload.get("renderer_id").and_then(Value::as_i64) {
let renderer_id = renderer_id as i32;
state
.session
.renderers
.retain(|r| r.renderer_id != renderer_id);
state.session_renderer_states.remove(&renderer_id);
refresh_local_renderer_id(&mut state.session);
sync_session_renderer_active_flags(&mut state);
}
}
"MESSAGE_TYPE_SRVR_CTRL_ACTIVE_RENDERER_CHANGED" => {
state.session.active_renderer_id = normalize_active_renderer_id(
payload.get("active_renderer_id").and_then(Value::as_i64),
);
if let (Some(active_renderer_id), Some(loop_mode)) =
(state.session.active_renderer_id, state.session_loop_mode)
{
let renderer_state =
ensure_session_renderer_state(&mut state, active_renderer_id);
renderer_state.loop_mode = Some(loop_mode);
renderer_state.updated_at_ms = qconnect_now_ms();
}
apply_loop_mode = state.session_loop_mode;
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 {
return;
};
let player_state = payload.get("player_state");
let renderer_state = ensure_session_renderer_state(&mut state, renderer_id as i32);
let Some(app) = self.app.get().and_then(Weak::upgrade) else {
return;
};
let identity = super::session::resolve_local_identity();
let outcome = app
.apply_session_management_event(message_type, payload, &identity)
.await;
if let Some(playing_state) = player_state
.and_then(|value| value.get("playing_state"))
.and_then(Value::as_i64)
.and_then(|value| i32::try_from(value).ok())
{
renderer_state.playing_state = Some(playing_state);
}
if let Some(current_position_ms) = player_state
.and_then(|value| value.get("current_position"))
.and_then(Value::as_i64)
.and_then(|value| u64::try_from(value).ok())
{
renderer_state.current_position_ms = Some(current_position_ms);
}
if let Some(current_queue_item_id) = player_state
.and_then(|value| value.get("current_queue_item_id"))
.and_then(Value::as_i64)
{
renderer_state.current_queue_item_id =
u64::try_from(current_queue_item_id).ok();
}
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 && 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 {
return;
};
let Some(volume) = payload
.get("volume")
.and_then(Value::as_i64)
.and_then(|value| i32::try_from(value).ok())
else {
return;
};
let renderer_state = ensure_session_renderer_state(&mut state, renderer_id as i32);
renderer_state.volume = Some(volume);
renderer_state.updated_at_ms = qconnect_now_ms();
}
"MESSAGE_TYPE_SRVR_CTRL_VOLUME_MUTED" => {
let Some(renderer_id) = payload.get("renderer_id").and_then(Value::as_i64) else {
return;
};
let Some(muted) = payload.get("value").and_then(Value::as_bool) else {
return;
};
let renderer_state = ensure_session_renderer_state(&mut state, renderer_id as i32);
renderer_state.muted = Some(muted);
renderer_state.updated_at_ms = qconnect_now_ms();
}
"MESSAGE_TYPE_SRVR_CTRL_MAX_AUDIO_QUALITY_CHANGED" => {
let Some(renderer_id) = payload.get("renderer_id").and_then(Value::as_i64) else {
return;
};
let Some(max_audio_quality) = payload
.get("max_audio_quality")
.and_then(Value::as_i64)
.and_then(|value| i32::try_from(value).ok())
else {
return;
};
let renderer_state = ensure_session_renderer_state(&mut state, renderer_id as i32);
renderer_state.max_audio_quality = Some(max_audio_quality);
renderer_state.updated_at_ms = qconnect_now_ms();
}
"MESSAGE_TYPE_SRVR_CTRL_LOOP_MODE_SET" => {
let Some(loop_mode) = payload
.get("loop_mode")
.and_then(Value::as_i64)
.and_then(|value| i32::try_from(value).ok())
else {
return;
};
state.session_loop_mode = Some(loop_mode);
apply_loop_mode = Some(loop_mode);
if let Some(active_renderer_id) = state.session.active_renderer_id {
let renderer_state =
ensure_session_renderer_state(&mut state, active_renderer_id);
renderer_state.loop_mode = Some(loop_mode);
renderer_state.updated_at_ms = qconnect_now_ms();
}
}
_ => {}
}
drop(state);
if let Some(loop_mode) = apply_loop_mode {
if let Some(loop_mode) = outcome.apply_loop_mode {
if let Err(err) =
apply_remote_loop_mode_to_corebridge(&self.core_bridge, loop_mode).await
{
@@ -504,92 +226,27 @@ impl TauriQconnectEventSink {
}
}
if sync_local_playback {
if outcome.sync_local_playback {
self.sync_local_playback_for_renderer_ownership().await;
}
if let Some(renderer_id) = remote_projection_renderer_id {
if let Some(renderer_id) = outcome.remote_projection_renderer_id {
self.sync_active_renderer_projection(renderer_id).await;
}
if let Some(renderer_id) = disconnected_renderer_id {
self.freeze_active_renderer_projection_to_unknown(
if let Some(renderer_id) = outcome.disconnected_renderer_id {
app.freeze_active_renderer_projection(
renderer_id,
QconnectAppEvent::RendererDisconnected { renderer_id },
)
.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,
QconnectAppEvent::RendererUnreachable { renderer_id },
)
.await;
}
});
if let Some((renderer_id, generation)) = outcome.watchdog_arm {
app.arm_renderer_watchdog(renderer_id, generation);
}
}
/// 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
/// signal a freeze event through the event sink. Shared by
/// ACTIVE_DISCONNECTED (P0-2) and the silence watchdog (P0-1). Caller passes
/// the `QconnectAppEvent` freeze variant (`RendererUnreachable` /
/// `RendererDisconnected`) to emit.
///
/// This is a pure-state mutation followed by `on_event(variant)`. The sink's
/// mapper arm for these variants ONLY emits the dedicated Tauri channel (+
/// the blanket `qconnect:event`); it does NOT route back through
/// `apply_session_management_event` or call this helper again, so it cannot
/// re-arm the watchdog or recurse.
async fn freeze_active_renderer_projection_to_unknown(
&self,
renderer_id: i32,
freeze_event: QconnectAppEvent,
) {
{
let mut state = self.sync_state.lock().await;
let renderer_state = ensure_session_renderer_state(&mut state, renderer_id);
renderer_state.playing_state = Some(PLAYING_STATE_UNKNOWN);
renderer_state.updated_at_ms = qconnect_now_ms();
}
self.on_event(freeze_event).await;
}
/// Emit one renderer-freeze dedicated channel (`qconnect:renderer_unreachable`
/// / `qconnect:renderer_disconnected`) with the legacy `{renderer_id}` payload.
/// Called from the `on_event` mapper arms; kept as a small helper so both
-7
View File
@@ -39,13 +39,6 @@ pub(super) const PLAYING_STATE_PLAYING: i32 = 2;
pub(super) const PLAYING_STATE_PAUSED: i32 = 3;
pub(super) const BUFFER_STATE_OK: i32 = 2;
/// The renderer-liveness silence budget and the pure watchdog-arming predicate
/// now live in the frontend-agnostic `qconnect_app::session` module. Re-exported
/// here so existing `super::…` references inside this module compile unchanged.
/// (The JoinSession `reason` consts also moved there; they are consumed only by
/// `deferred_join_reason`, which moved too, so they are not re-exported here.)
pub(super) use qconnect_app::{should_arm_renderer_watchdog, QCONNECT_RENDERER_LOST_TIMEOUT_MS};
// 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;
+8 -35
View File
@@ -35,7 +35,7 @@ pub use qconnect_app::{QconnectRendererInfo, QconnectSessionRendererState, Qconn
/// qconnect-app (slice 2+4). Re-exported so existing `super::session::…` /
/// `super::…` references compile unchanged.
pub(super) use qconnect_app::{
is_local_renderer_active, is_peer_renderer_active, normalize_active_renderer_id,
ensure_session_renderer_state, is_local_renderer_active, is_peer_renderer_active,
renderer_allows_remote_volume, QconnectFileAudioQualitySnapshot,
};
// find_unique_renderer_id's sole non-test caller (refresh_local_renderer_id)
@@ -76,49 +76,22 @@ pub(crate) struct QconnectRendererReportDebugEvent {
pub(super) resolution_strategy: String,
}
/// Thin Tauri wrapper: resolves this device's identity (uuid + device-info)
/// adapter-side, then delegates to the frontend-agnostic resolver in
/// qconnect-app. qconnect-app stays identity-free. The eager device-info build
/// is idempotent and side-effect-free (env reads + the cached uuid), so the
/// resolved renderer id is identical to the prior in-place implementation.
pub(super) fn refresh_local_renderer_id(session: &mut QconnectSessionState) {
/// Resolve THIS device's identity (uuid + device-info) for injection into the
/// frontend-agnostic session-apply logic in qconnect-app. The device-info build
/// is idempotent and side-effect-free (env reads + the cached uuid), so
/// resolving it here once per session event yields the exact renderer-id
/// resolution the prior in-place `refresh_local_renderer_id` produced.
pub(super) fn resolve_local_identity() -> qconnect_app::LocalIdentity {
let info = default_qconnect_device_info();
let identity = qconnect_app::LocalIdentity {
qconnect_app::LocalIdentity {
device_uuid: info.device_uuid.unwrap_or_default(),
friendly_name: info.friendly_name,
brand: info.brand,
model: info.model,
device_type: info.device_type,
};
qconnect_app::refresh_local_renderer_id(session, &identity);
}
pub(super) fn sync_session_renderer_active_flags(state: &mut QconnectRemoteSyncState) {
for (renderer_id, renderer_state) in &mut state.session_renderer_states {
renderer_state.active = state
.session
.active_renderer_id
.map(|active_renderer_id| active_renderer_id == *renderer_id);
}
}
pub(super) fn ensure_session_renderer_state(
state: &mut QconnectRemoteSyncState,
renderer_id: i32,
) -> &mut QconnectSessionRendererState {
let active = state
.session
.active_renderer_id
.map(|active_renderer_id| active_renderer_id == renderer_id);
state
.session_renderer_states
.entry(renderer_id)
.or_insert_with(|| QconnectSessionRendererState {
active,
..Default::default()
})
}
pub(super) fn build_session_renderer_snapshot(
queue: &QConnectQueueState,
renderer_state: Option<&QconnectSessionRendererState>,
+4 -4
View File
@@ -8,7 +8,7 @@ use super::queue_resolution::{
QconnectRemoteSkipDirection,
};
use super::session::{
find_unique_renderer_id, refresh_local_renderer_id, renderer_allows_remote_volume,
find_unique_renderer_id, renderer_allows_remote_volume, resolve_local_identity,
QconnectFileAudioQualitySnapshot,
};
use super::transport::{
@@ -138,7 +138,7 @@ fn refreshes_local_renderer_id_from_exact_device_uuid_match() {
..Default::default()
};
refresh_local_renderer_id(&mut session);
qconnect_app::refresh_local_renderer_id(&mut session, &resolve_local_identity());
assert_eq!(session.local_renderer_id, Some(6));
}
@@ -172,7 +172,7 @@ fn refreshes_local_renderer_id_from_unique_fingerprint_when_uuid_missing() {
..Default::default()
};
refresh_local_renderer_id(&mut session);
qconnect_app::refresh_local_renderer_id(&mut session, &resolve_local_identity());
assert_eq!(session.local_renderer_id, Some(6));
}
@@ -203,7 +203,7 @@ fn does_not_guess_local_renderer_id_when_fingerprint_is_ambiguous() {
..Default::default()
};
refresh_local_renderer_id(&mut session);
qconnect_app::refresh_local_renderer_id(&mut session, &resolve_local_identity());
assert_eq!(session.local_renderer_id, None);
assert_eq!(