feat(core): A.6 — supervisor reconnect with watchdog and event stream
Adds an end-to-end auto-reconnect path so a brief network outage no longer leaves the client wedged in a half-dead state. The flow has three layers, each motivated by a real failure mode observed on the Moto G live test: * `chanora_protocol::DisconnectReason` (`UserRequested` / `StreamEnded` / `Error(String)`) is reported on a `oneshot` when the per-connection task exits, so the supervisor can tell user intent apart from a real loss. * `chanora_core` spawns a supervisor task per `ChanoraSession`. It listens for the loss notifier AND runs a watchdog that issues `snapshot()` probes every 5s with a 4s timeout — three consecutive misses synthesise a `DisconnectReason::Error(...)` and trigger the reconnect path. The watchdog catches the "ghost connected" case where tsclientlib silently resets internal state but the event stream never errors. Backoff schedule: 1s, 2s, 5s, 15s, 30s, 60s (capped). On success the supervisor swaps the dead `ProtocolClient` for the new one in place and, if audio was running, restarts the audio engine bound to the new `voice_in`/`voice_out` channels. * `SessionEvent` (Connected / Lost / Reconnecting / Disconnected / AudioStarted / AudioStopped) is broadcast on a 64-slot channel. `chanora_bridge` re-exports it as `BridgeEvent` and exposes `events_stream(StreamSink)`; the Flutter side subscribes from `initState` and renders a reconnect banner with attempt count and delay. New `SnapshotProbe` exposes a clone-friendly snapshot path so the watchdog can probe without holding `&self` across awaits. Localization adds `statusReconnecting` and `statusConnectionLost` keys to `app_en.arb` and `app_zh.arb`. Verified on Moto G Stylus 5G (Android 14) against cn.teamspeak.app: killed Wi-Fi + cellular for ~70 s; watchdog declared loss at three misses, supervisor walked the backoff schedule, and the UI reconnected automatically once the radios came back. Snapshot tree re-rendered without user action.
This commit is contained in:
@@ -11,8 +11,9 @@ use std::time::Duration;
|
||||
|
||||
use flutter_rust_bridge::frb;
|
||||
use tokio::runtime::Runtime;
|
||||
use tracing::info;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::frb_generated::StreamSink;
|
||||
use crate::BridgeError;
|
||||
|
||||
/// Process-wide tokio runtime used to drive the async core. Created
|
||||
@@ -232,6 +233,93 @@ pub struct BridgeAudioStats {
|
||||
pub ptt_active: bool,
|
||||
}
|
||||
|
||||
// ---------- Events (A.6) ----------
|
||||
|
||||
/// Lifecycle event surfaced to Dart. Schema-controlled mirror of
|
||||
/// [`chanora_core::SessionEvent`] — no core types cross the bridge.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum BridgeEvent {
|
||||
/// Initial connect succeeded, or a reconnect attempt succeeded.
|
||||
Connected {
|
||||
/// Server name reported by the server snapshot.
|
||||
server_name: String,
|
||||
},
|
||||
/// Connection lost; supervisor will retry.
|
||||
Lost {
|
||||
/// Reason classification from the protocol layer.
|
||||
reason: String,
|
||||
},
|
||||
/// Supervisor is sleeping before its next reconnect attempt.
|
||||
Reconnecting {
|
||||
/// 1-based attempt counter for the current outage.
|
||||
attempt: u32,
|
||||
/// Seconds the supervisor will sleep before this attempt.
|
||||
delay_secs: u32,
|
||||
},
|
||||
/// Session ended (user-requested disconnect or unrecoverable).
|
||||
Disconnected {
|
||||
/// Reason classification.
|
||||
reason: String,
|
||||
},
|
||||
/// Audio engine started.
|
||||
AudioStarted,
|
||||
/// Audio engine stopped.
|
||||
AudioStopped,
|
||||
}
|
||||
|
||||
impl From<chanora_core::SessionEvent> for BridgeEvent {
|
||||
fn from(e: chanora_core::SessionEvent) -> Self {
|
||||
match e {
|
||||
chanora_core::SessionEvent::Connected { server_name } => {
|
||||
BridgeEvent::Connected { server_name }
|
||||
}
|
||||
chanora_core::SessionEvent::Lost { reason } => BridgeEvent::Lost { reason },
|
||||
chanora_core::SessionEvent::Reconnecting {
|
||||
attempt,
|
||||
delay_secs,
|
||||
} => BridgeEvent::Reconnecting {
|
||||
attempt,
|
||||
delay_secs,
|
||||
},
|
||||
chanora_core::SessionEvent::Disconnected { reason } => {
|
||||
BridgeEvent::Disconnected { reason }
|
||||
}
|
||||
chanora_core::SessionEvent::AudioStarted => BridgeEvent::AudioStarted,
|
||||
chanora_core::SessionEvent::AudioStopped => BridgeEvent::AudioStopped,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribe to lifecycle events. Each call yields a fresh
|
||||
/// subscription; multiple subscribers are supported. On slow
|
||||
/// consumers, events are dropped rather than blocking the supervisor
|
||||
/// (consistent with `tokio::sync::broadcast::Receiver` semantics).
|
||||
pub fn events_stream(sink: StreamSink<BridgeEvent>) -> Result<(), BridgeError> {
|
||||
let mut rx = session().subscribe_events();
|
||||
runtime().spawn(async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(evt) => {
|
||||
if sink.add(BridgeEvent::from(evt)).is_err() {
|
||||
// Dart side closed the sink — stop the bridge task.
|
||||
info!(target: "chanora_bridge", "events_stream: dart sink closed");
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!(target: "chanora_bridge", "events_stream: lagged, dropped {n} events");
|
||||
continue;
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||
info!(target: "chanora_bridge", "events_stream: source closed");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read audio statistics. Errors if no connection or audio not started.
|
||||
pub async fn audio_stats() -> Result<BridgeAudioStats, BridgeError> {
|
||||
let (s, r, p) = runtime()
|
||||
|
||||
Reference in New Issue
Block a user