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:
@@ -60,9 +60,57 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
bool _audioStarted = false;
|
||||
rust.BridgeAudioStats? _audioStats;
|
||||
Timer? _statsTimer;
|
||||
StreamSubscription<rust.BridgeEvent>? _eventsSub;
|
||||
|
||||
// A.6 reconnect banner state.
|
||||
String? _lostReason;
|
||||
int? _reconnectAttempt;
|
||||
int? _reconnectDelay;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_eventsSub = rust.eventsStream().listen(_onEvent);
|
||||
}
|
||||
|
||||
void _onEvent(rust.BridgeEvent evt) {
|
||||
if (!mounted) return;
|
||||
switch (evt) {
|
||||
case rust.BridgeEvent_Connected():
|
||||
setState(() {
|
||||
_phase = _Phase.connected;
|
||||
_lostReason = null;
|
||||
_reconnectAttempt = null;
|
||||
_reconnectDelay = null;
|
||||
});
|
||||
case rust.BridgeEvent_Lost(:final reason):
|
||||
setState(() {
|
||||
_lostReason = reason;
|
||||
_reconnectAttempt = null;
|
||||
_reconnectDelay = null;
|
||||
});
|
||||
case rust.BridgeEvent_Reconnecting(:final attempt, :final delaySecs):
|
||||
setState(() {
|
||||
_reconnectAttempt = attempt;
|
||||
_reconnectDelay = delaySecs;
|
||||
});
|
||||
case rust.BridgeEvent_Disconnected():
|
||||
setState(() {
|
||||
_phase = _Phase.idle;
|
||||
_lostReason = null;
|
||||
_reconnectAttempt = null;
|
||||
_reconnectDelay = null;
|
||||
});
|
||||
case rust.BridgeEvent_AudioStarted():
|
||||
setState(() => _audioStarted = true);
|
||||
case rust.BridgeEvent_AudioStopped():
|
||||
setState(() => _audioStarted = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_eventsSub?.cancel();
|
||||
_statsTimer?.cancel();
|
||||
_hostCtl.dispose();
|
||||
_nickCtl.dispose();
|
||||
@@ -201,6 +249,42 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(statusText(), style: theme.textTheme.titleMedium),
|
||||
if (_lostReason != null || _reconnectAttempt != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_reconnectAttempt != null
|
||||
? l10n.statusReconnecting(
|
||||
_reconnectAttempt!,
|
||||
_reconnectDelay ?? 0,
|
||||
)
|
||||
: l10n.statusConnectionLost(_lostReason ?? ''),
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
if (_phase == _Phase.idle) ...[
|
||||
_ConnectForm(
|
||||
|
||||
Reference in New Issue
Block a user