From 09cd3be874cf5c0bfa3c8f602016d986b2b20a8e Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Wed, 10 Jun 2026 01:40:41 -0600 Subject: [PATCH] feat(slint): QConnect session loop parks on the connectivity watch while offline The host owns no retry timer (backoff + 60s idle rearm live inside the WS transport), so the gate lands at the SessionLoopHost hooks: the keep-idling branch of on_reconnect_exhausted and bootstrap_after_reconnect now park on the offline engine's watch channel until the online edge instead of churning per rearm cycle. Event-driven, lock-free parking; the D5 watcher's disconnect aborts the parked future via the event-loop abort. Converting the bootstrap skip into a park also closes the dormant-session hole where a reconnect that subscribed while offline stayed joined but never bootstrapped. No auto-reconnect on the online edge (D5); transport internals, watchdogs and queue flows untouched. cc --- crates/qbz-slint/src/qconnect_service.rs | 58 ++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 4 deletions(-) diff --git a/crates/qbz-slint/src/qconnect_service.rs b/crates/qbz-slint/src/qconnect_service.rs index 556368d0c..1f03ad840 100644 --- a/crates/qbz-slint/src/qconnect_service.rs +++ b/crates/qbz-slint/src/qconnect_service.rs @@ -2159,6 +2159,35 @@ impl SlintQconnectService { } } +/// B12 (offline-MODE): park the calling session-loop seam while the offline +/// engine reports offline, waking ONLY on the online edge — event-driven via +/// the engine's watch channel, no polling/sleeping. Session teardown needs no +/// extra signal: the D5 force-disconnect watcher's `disconnect()` aborts the +/// session-loop task (`runtime.event_loop.abort()`), which cancels this future +/// at the `.await`. Per D5 this never triggers a reconnect by itself — when the +/// online edge arrives with the session still alive, the caller simply resumes +/// its normal behavior. MUST be called WITHOUT holding any service lock (it +/// parks indefinitely; `disconnect()` needs the `inner` lock to tear down). +async fn park_session_loop_while_offline(context: &str) { + let mut rx = crate::offline_mode::engine().subscribe(); + if !rx.borrow_and_update().is_offline() { + return; + } + log::info!( + "[QConnect] {context}: offline mode active; parking the session loop until the online edge or teardown (B12)" + ); + loop { + if rx.changed().await.is_err() { + // Engine sender dropped (process teardown) — nothing left to gate. + return; + } + if !rx.borrow_and_update().is_offline() { + break; + } + } + log::info!("[QConnect] {context}: online edge observed; resuming the session loop (B12)"); +} + /// Slint-side implementation of the shared session-loop seams (piece c). Holds /// the handles the loop reaches back into: the app (renderer join + state reads), /// the shared sync accumulator, the service inner (lifecycle gating + teardown), @@ -2182,10 +2211,13 @@ impl SessionLoopHost for SlintSessionLoopHost { // force-disconnect watcher is tearing the session down on the offline // edge; a transport reconnect that sneaks in before that lands (induced // offline keeps the network up) must stay dormant, not re-join. - if crate::offline_mode::engine().is_offline() { - log::info!("[QConnect] Reconnect bootstrap suppressed: offline mode active (D5)"); - return; - } + // B12: the suppression is event-driven — instead of skipping (which + // left a subscribed-but-never-bootstrapped session dormant forever if + // the watcher's teardown raced or failed), park on the offline + // engine's watch channel. Teardown aborts the loop task (cancelling + // this wait); if the session is still alive when the online edge + // arrives, the normal bootstrap below resumes. + park_session_loop_while_offline("Reconnect bootstrap").await; if let Err(err) = bootstrap_remote_presence(&self.app, None).await { log::error!("[QConnect] Re-bootstrap after reconnect failed: {err}"); } @@ -2223,6 +2255,24 @@ impl SessionLoopHost for SlintSessionLoopHost { } // TODO(slint-qconnect-ui): surface the Exhausted lifecycle on the badge. log::warn!("[QConnect] Reconnect exhausted ({attempts}): {last_reason}"); + // B12 (offline-MODE): the reconnect backoff loop AND the 60s idle + // rearm both live INSIDE qconnect-transport-ws + // (`idle_retry_after_exhausted`) — the host owns no retry timer it + // could gate, so this hook (the host-controlled point the loop hits + // once per rearm cycle) is the correct seam. While offline, park on + // the offline engine's watch channel instead of returning into + // another consume-and-churn cycle: the host stops burning + // lifecycle/lock/log cycles per rearm until either the online edge + // (idle-retry behavior resumes — per D5 the park itself never + // reconnects) or the D5 watcher's `disconnect()` aborts the loop task + // (cancelling the park). The transport's internal timer keeps + // rearming independently until that teardown lands; its events queue + // in the broadcast channel and drain on resume (the loop's Lagged + // recovery covers a pathologically long park). Only parked on the + // keep-idling branch — the terminate branch breaks the loop anyway. + if idle_retry_active { + park_session_loop_while_offline("Idle-retry rearm").await; + } !idle_retry_active }