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 tokio::sync::Mutex;
use uuid::Uuid; use uuid::Uuid;
use crate::{QconnectAppError, QconnectAppEvent, QconnectEventSink, QconnectRuntimeState}; use crate::{
QconnectAppError, QconnectAppEvent, QconnectEventSink, QconnectRemoteSyncState,
QconnectRuntimeState,
};
pub struct QconnectApp<TTransport, TSink> pub struct QconnectApp<TTransport, TSink>
where where
@@ -28,6 +31,15 @@ where
transport: Arc<TTransport>, transport: Arc<TTransport>,
sink: Arc<TSink>, sink: Arc<TSink>,
state: Arc<Mutex<QconnectRuntimeState>>, 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> impl<TTransport, TSink> Clone for QconnectApp<TTransport, TSink>
@@ -40,6 +52,7 @@ where
transport: Arc::clone(&self.transport), transport: Arc::clone(&self.transport),
sink: Arc::clone(&self.sink), sink: Arc::clone(&self.sink),
state: Arc::clone(&self.state), state: Arc::clone(&self.state),
sync: Arc::clone(&self.sync),
} }
} }
} }
@@ -51,11 +64,20 @@ where
{ {
const PENDING_ACTION_TIMEOUT_MS: u64 = 10_000; 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 { Self {
transport, transport,
sink, sink,
state: Arc::new(Mutex::new(QconnectRuntimeState::default())), state: Arc::new(Mutex::new(QconnectRuntimeState::default())),
sync,
} }
} }
@@ -63,6 +85,13 @@ where
Arc::clone(&self.state) 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> { pub fn subscribe_transport_events(&self) -> tokio::sync::broadcast::Receiver<TransportEvent> {
self.transport.subscribe() self.transport.subscribe()
} }
@@ -1006,6 +1035,8 @@ mod tests {
use async_trait::async_trait; use async_trait::async_trait;
use qconnect_core::QueueVersion; use qconnect_core::QueueVersion;
use qconnect_transport_ws::InMemoryWsTransport; use qconnect_transport_ws::InMemoryWsTransport;
use crate::QconnectRemoteSyncState;
use serde_json::json; use serde_json::json;
use tokio::sync::Mutex; use tokio::sync::Mutex;
@@ -1053,7 +1084,11 @@ mod tests {
) { ) {
let transport = Arc::new(InMemoryWsTransport::new()); let transport = Arc::new(InMemoryWsTransport::new());
let sink = TestSink::default(); 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(); let events_rx = app.subscribe_transport_events();
app.connect(test_config()).await.expect("connect"); app.connect(test_config()).await.expect("connect");
(app, sink, transport, events_rx) (app, sink, transport, events_rx)
+5 -1
View File
@@ -304,7 +304,11 @@ impl QconnectServiceState {
sync_state: Arc::clone(&sync_state), sync_state: Arc::clone(&sync_state),
app: Arc::new(std::sync::OnceLock::new()), app: Arc::new(std::sync::OnceLock::new()),
}); });
let app = Arc::new(QconnectApp::new(transport, Arc::clone(&sink))); let app = Arc::new(QconnectApp::new(
transport,
Arc::clone(&sink),
Arc::clone(&sync_state),
));
// P1-6: wire the owning app into the sink so it can emit reports // P1-6: wire the owning app into the sink so it can emit reports
// (e.g. is_active=true after SetActive(true)). // (e.g. is_active=true after SetActive(true)).
sink.set_app(&app); sink.set_app(&app);