feat(qconnect): own remote-sync Mutex on QconnectApp via new(transport, sink, sync) + sync_handle (slice 2+4)

QconnectApp now holds the shared QconnectRemoteSyncState accumulator behind
its own Mutex, disjoint from `state` (the two are never co-locked). The Tauri
adapter still builds the Arc and passes the SAME clone to its event sink /
service loop and into new(), so there is exactly one lock backing the
session/liveness/renderer paths -- the app simply gains a handle to it via
sync_handle(). Pure wiring, byte-identical locking, no logic moved. Prepares
slices 2+4.4/5 to relocate the session-apply + watchdog logic into qconnect-app.

cc
This commit is contained in:
vicrodh committed 2026-06-02 13:22:41 -06:00
1 parent a79971e470
commit 7b143c2738
2 files changed
+43 -4

No files matched your search

+38 -3
View File
@@ -18,7 +18,10 @@ use serde_json::Value;
use tokio::sync::Mutex;
use uuid::Uuid;
use crate::{QconnectAppError, QconnectAppEvent, QconnectEventSink, QconnectRuntimeState};
use crate::{
QconnectAppError, QconnectAppEvent, QconnectEventSink, QconnectRemoteSyncState,
QconnectRuntimeState,
};
pub struct QconnectApp<TTransport, TSink>
where
@@ -28,6 +31,15 @@ where
transport: Arc<TTransport>,
sink: Arc<TSink>,
state: Arc<Mutex<QconnectRuntimeState>>,
/// Cross-frontend remote-sync accumulator (session topology, renderer-state
/// cache, materialization cache, load-attempt dedup, watchdog epoch). Held
/// behind its OWN Mutex, disjoint from `state`: the two are never co-locked,
/// so the renderer-report hot path never serializes against session/watchdog
/// work and there is no deadlock edge against `clear_pending_if_matches`
/// (which locks `state`). The session loop (slice 5) and the renderer engine
/// (slice 6) both lock THIS one, exactly as they share it today via the Tauri
/// adapter. See `MASTER-qconnect-to-slint-plan.md` §0 (one Mutex, not two).
sync: Arc<Mutex<QconnectRemoteSyncState>>,
}
impl<TTransport, TSink> Clone for QconnectApp<TTransport, TSink>
@@ -40,6 +52,7 @@ where
transport: Arc::clone(&self.transport),
sink: Arc::clone(&self.sink),
state: Arc::clone(&self.state),
sync: Arc::clone(&self.sync),
}
}
}
@@ -51,11 +64,20 @@ where
{
const PENDING_ACTION_TIMEOUT_MS: u64 = 10_000;
pub fn new(transport: Arc<TTransport>, sink: Arc<TSink>) -> Self {
/// Construct the app. `sync` is the shared remote-sync accumulator handle:
/// the Tauri adapter builds it, hands a clone to its event sink / service
/// loop, and passes the SAME `Arc` here so there is exactly one lock backing
/// the session/liveness/renderer paths. The Slint adapter will do the same.
pub fn new(
transport: Arc<TTransport>,
sink: Arc<TSink>,
sync: Arc<Mutex<QconnectRemoteSyncState>>,
) -> Self {
Self {
transport,
sink,
state: Arc::new(Mutex::new(QconnectRuntimeState::default())),
sync,
}
}
@@ -63,6 +85,13 @@ where
Arc::clone(&self.state)
}
/// Shared handle to the remote-sync accumulator. The adapter uses this so its
/// event sink, service loop, and CoreBridge helpers lock the very same Mutex
/// the app's own session/watchdog methods lock.
pub fn sync_handle(&self) -> Arc<Mutex<QconnectRemoteSyncState>> {
Arc::clone(&self.sync)
}
pub fn subscribe_transport_events(&self) -> tokio::sync::broadcast::Receiver<TransportEvent> {
self.transport.subscribe()
}
@@ -1006,6 +1035,8 @@ mod tests {
use async_trait::async_trait;
use qconnect_core::QueueVersion;
use qconnect_transport_ws::InMemoryWsTransport;
use crate::QconnectRemoteSyncState;
use serde_json::json;
use tokio::sync::Mutex;
@@ -1053,7 +1084,11 @@ mod tests {
) {
let transport = Arc::new(InMemoryWsTransport::new());
let sink = TestSink::default();
let app = QconnectApp::new(Arc::clone(&transport), Arc::new(sink.clone()));
let app = QconnectApp::new(
Arc::clone(&transport),
Arc::new(sink.clone()),
Arc::new(Mutex::new(QconnectRemoteSyncState::default())),
);
let events_rx = app.subscribe_transport_events();
app.connect(test_config()).await.expect("connect");
(app, sink, transport, events_rx)