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:
@@ -6,9 +6,11 @@
|
||||
import 'frb_generated.dart';
|
||||
import 'lib.dart';
|
||||
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart' hide protected;
|
||||
part 'api.freezed.dart';
|
||||
|
||||
// These functions are ignored because they are not marked as `pub`: `runtime`, `session`
|
||||
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `clone`, `clone`, `clone`, `fmt`, `fmt`, `fmt`, `fmt`, `from`
|
||||
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `clone`, `clone`, `clone`, `clone`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`
|
||||
|
||||
/// Connect to a TeamSpeak-compatible server and return the initial
|
||||
/// state snapshot. Honours the DEC-006 single-connection invariant
|
||||
@@ -35,6 +37,13 @@ Future<void> startAudio() => RustLib.instance.api.crateApiStartAudio();
|
||||
Future<void> setPtt({required bool active}) =>
|
||||
RustLib.instance.api.crateApiSetPtt(active: active);
|
||||
|
||||
/// 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).
|
||||
Stream<BridgeEvent> eventsStream() =>
|
||||
RustLib.instance.api.crateApiEventsStream();
|
||||
|
||||
/// Read audio statistics. Errors if no connection or audio not started.
|
||||
Future<BridgeAudioStats> audioStats() =>
|
||||
RustLib.instance.api.crateApiAudioStats();
|
||||
@@ -138,6 +147,44 @@ class BridgeClient {
|
||||
name == other.name;
|
||||
}
|
||||
|
||||
@freezed
|
||||
sealed class BridgeEvent with _$BridgeEvent {
|
||||
const BridgeEvent._();
|
||||
|
||||
/// Initial connect succeeded, or a reconnect attempt succeeded.
|
||||
const factory BridgeEvent.connected({
|
||||
/// Server name reported by the server snapshot.
|
||||
required String serverName,
|
||||
}) = BridgeEvent_Connected;
|
||||
|
||||
/// Connection lost; supervisor will retry.
|
||||
const factory BridgeEvent.lost({
|
||||
/// Reason classification from the protocol layer.
|
||||
required String reason,
|
||||
}) = BridgeEvent_Lost;
|
||||
|
||||
/// Supervisor is sleeping before its next reconnect attempt.
|
||||
const factory BridgeEvent.reconnecting({
|
||||
/// 1-based attempt counter for the current outage.
|
||||
required int attempt,
|
||||
|
||||
/// Seconds the supervisor will sleep before this attempt.
|
||||
required int delaySecs,
|
||||
}) = BridgeEvent_Reconnecting;
|
||||
|
||||
/// Session ended (user-requested disconnect or unrecoverable).
|
||||
const factory BridgeEvent.disconnected({
|
||||
/// Reason classification.
|
||||
required String reason,
|
||||
}) = BridgeEvent_Disconnected;
|
||||
|
||||
/// Audio engine started.
|
||||
const factory BridgeEvent.audioStarted() = BridgeEvent_AudioStarted;
|
||||
|
||||
/// Audio engine stopped.
|
||||
const factory BridgeEvent.audioStopped() = BridgeEvent_AudioStopped;
|
||||
}
|
||||
|
||||
/// Server snapshot as seen by Dart.
|
||||
class BridgeSnapshot {
|
||||
/// Server name.
|
||||
|
||||
Reference in New Issue
Block a user