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:
EdisonJwa
2026-05-15 01:06:07 +08:00
parent bc0da50cdb
commit 0bef61aea2
18 changed files with 1929 additions and 74 deletions
@@ -55,11 +55,12 @@ extension BridgeErrorPatterns on BridgeError {
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>({TResult Function( BridgeError_InvalidCommand value)? invalidCommand,TResult Function( BridgeError_Connection value)? connection,TResult Function( BridgeError_NotConnected value)? notConnected,TResult Function( BridgeError_AlreadyConnected value)? alreadyConnected,TResult Function( BridgeError_Unmapped value)? unmapped,required TResult orElse(),}){
@optionalTypeArgs TResult maybeMap<TResult extends Object?>({TResult Function( BridgeError_InvalidCommand value)? invalidCommand,TResult Function( BridgeError_DnsFailed value)? dnsFailed,TResult Function( BridgeError_Connection value)? connection,TResult Function( BridgeError_NotConnected value)? notConnected,TResult Function( BridgeError_AlreadyConnected value)? alreadyConnected,TResult Function( BridgeError_Unmapped value)? unmapped,required TResult orElse(),}){
final _that = this;
switch (_that) {
case BridgeError_InvalidCommand() when invalidCommand != null:
return invalidCommand(_that);case BridgeError_Connection() when connection != null:
return invalidCommand(_that);case BridgeError_DnsFailed() when dnsFailed != null:
return dnsFailed(_that);case BridgeError_Connection() when connection != null:
return connection(_that);case BridgeError_NotConnected() when notConnected != null:
return notConnected(_that);case BridgeError_AlreadyConnected() when alreadyConnected != null:
return alreadyConnected(_that);case BridgeError_Unmapped() when unmapped != null:
@@ -81,11 +82,12 @@ return unmapped(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>({required TResult Function( BridgeError_InvalidCommand value) invalidCommand,required TResult Function( BridgeError_Connection value) connection,required TResult Function( BridgeError_NotConnected value) notConnected,required TResult Function( BridgeError_AlreadyConnected value) alreadyConnected,required TResult Function( BridgeError_Unmapped value) unmapped,}){
@optionalTypeArgs TResult map<TResult extends Object?>({required TResult Function( BridgeError_InvalidCommand value) invalidCommand,required TResult Function( BridgeError_DnsFailed value) dnsFailed,required TResult Function( BridgeError_Connection value) connection,required TResult Function( BridgeError_NotConnected value) notConnected,required TResult Function( BridgeError_AlreadyConnected value) alreadyConnected,required TResult Function( BridgeError_Unmapped value) unmapped,}){
final _that = this;
switch (_that) {
case BridgeError_InvalidCommand():
return invalidCommand(_that);case BridgeError_Connection():
return invalidCommand(_that);case BridgeError_DnsFailed():
return dnsFailed(_that);case BridgeError_Connection():
return connection(_that);case BridgeError_NotConnected():
return notConnected(_that);case BridgeError_AlreadyConnected():
return alreadyConnected(_that);case BridgeError_Unmapped():
@@ -103,11 +105,12 @@ return unmapped(_that);}
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>({TResult? Function( BridgeError_InvalidCommand value)? invalidCommand,TResult? Function( BridgeError_Connection value)? connection,TResult? Function( BridgeError_NotConnected value)? notConnected,TResult? Function( BridgeError_AlreadyConnected value)? alreadyConnected,TResult? Function( BridgeError_Unmapped value)? unmapped,}){
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>({TResult? Function( BridgeError_InvalidCommand value)? invalidCommand,TResult? Function( BridgeError_DnsFailed value)? dnsFailed,TResult? Function( BridgeError_Connection value)? connection,TResult? Function( BridgeError_NotConnected value)? notConnected,TResult? Function( BridgeError_AlreadyConnected value)? alreadyConnected,TResult? Function( BridgeError_Unmapped value)? unmapped,}){
final _that = this;
switch (_that) {
case BridgeError_InvalidCommand() when invalidCommand != null:
return invalidCommand(_that);case BridgeError_Connection() when connection != null:
return invalidCommand(_that);case BridgeError_DnsFailed() when dnsFailed != null:
return dnsFailed(_that);case BridgeError_Connection() when connection != null:
return connection(_that);case BridgeError_NotConnected() when notConnected != null:
return notConnected(_that);case BridgeError_AlreadyConnected() when alreadyConnected != null:
return alreadyConnected(_that);case BridgeError_Unmapped() when unmapped != null:
@@ -128,10 +131,11 @@ return unmapped(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function( String field0)? invalidCommand,TResult Function( String field0)? connection,TResult Function()? notConnected,TResult Function()? alreadyConnected,TResult Function( String field0)? unmapped,required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function( String field0)? invalidCommand,TResult Function( String host, String reason)? dnsFailed,TResult Function( String field0)? connection,TResult Function()? notConnected,TResult Function()? alreadyConnected,TResult Function( String field0)? unmapped,required TResult orElse(),}) {final _that = this;
switch (_that) {
case BridgeError_InvalidCommand() when invalidCommand != null:
return invalidCommand(_that.field0);case BridgeError_Connection() when connection != null:
return invalidCommand(_that.field0);case BridgeError_DnsFailed() when dnsFailed != null:
return dnsFailed(_that.host,_that.reason);case BridgeError_Connection() when connection != null:
return connection(_that.field0);case BridgeError_NotConnected() when notConnected != null:
return notConnected();case BridgeError_AlreadyConnected() when alreadyConnected != null:
return alreadyConnected();case BridgeError_Unmapped() when unmapped != null:
@@ -153,10 +157,11 @@ return unmapped(_that.field0);case _:
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function( String field0) invalidCommand,required TResult Function( String field0) connection,required TResult Function() notConnected,required TResult Function() alreadyConnected,required TResult Function( String field0) unmapped,}) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function( String field0) invalidCommand,required TResult Function( String host, String reason) dnsFailed,required TResult Function( String field0) connection,required TResult Function() notConnected,required TResult Function() alreadyConnected,required TResult Function( String field0) unmapped,}) {final _that = this;
switch (_that) {
case BridgeError_InvalidCommand():
return invalidCommand(_that.field0);case BridgeError_Connection():
return invalidCommand(_that.field0);case BridgeError_DnsFailed():
return dnsFailed(_that.host,_that.reason);case BridgeError_Connection():
return connection(_that.field0);case BridgeError_NotConnected():
return notConnected();case BridgeError_AlreadyConnected():
return alreadyConnected();case BridgeError_Unmapped():
@@ -174,10 +179,11 @@ return unmapped(_that.field0);}
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function( String field0)? invalidCommand,TResult? Function( String field0)? connection,TResult? Function()? notConnected,TResult? Function()? alreadyConnected,TResult? Function( String field0)? unmapped,}) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function( String field0)? invalidCommand,TResult? Function( String host, String reason)? dnsFailed,TResult? Function( String field0)? connection,TResult? Function()? notConnected,TResult? Function()? alreadyConnected,TResult? Function( String field0)? unmapped,}) {final _that = this;
switch (_that) {
case BridgeError_InvalidCommand() when invalidCommand != null:
return invalidCommand(_that.field0);case BridgeError_Connection() when connection != null:
return invalidCommand(_that.field0);case BridgeError_DnsFailed() when dnsFailed != null:
return dnsFailed(_that.host,_that.reason);case BridgeError_Connection() when connection != null:
return connection(_that.field0);case BridgeError_NotConnected() when notConnected != null:
return notConnected();case BridgeError_AlreadyConnected() when alreadyConnected != null:
return alreadyConnected();case BridgeError_Unmapped() when unmapped != null:
@@ -258,6 +264,76 @@ as String,
/// @nodoc
class BridgeError_DnsFailed extends BridgeError {
const BridgeError_DnsFailed({required this.host, required this.reason}): super._();
/// The hostname (or `host:port`) the caller submitted.
final String host;
/// Reason from the platform resolver.
final String reason;
/// Create a copy of BridgeError
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$BridgeError_DnsFailedCopyWith<BridgeError_DnsFailed> get copyWith => _$BridgeError_DnsFailedCopyWithImpl<BridgeError_DnsFailed>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeError_DnsFailed&&(identical(other.host, host) || other.host == host)&&(identical(other.reason, reason) || other.reason == reason));
}
@override
int get hashCode => Object.hash(runtimeType,host,reason);
@override
String toString() {
return 'BridgeError.dnsFailed(host: $host, reason: $reason)';
}
}
/// @nodoc
abstract mixin class $BridgeError_DnsFailedCopyWith<$Res> implements $BridgeErrorCopyWith<$Res> {
factory $BridgeError_DnsFailedCopyWith(BridgeError_DnsFailed value, $Res Function(BridgeError_DnsFailed) _then) = _$BridgeError_DnsFailedCopyWithImpl;
@useResult
$Res call({
String host, String reason
});
}
/// @nodoc
class _$BridgeError_DnsFailedCopyWithImpl<$Res>
implements $BridgeError_DnsFailedCopyWith<$Res> {
_$BridgeError_DnsFailedCopyWithImpl(this._self, this._then);
final BridgeError_DnsFailed _self;
final $Res Function(BridgeError_DnsFailed) _then;
/// Create a copy of BridgeError
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') $Res call({Object? host = null,Object? reason = null,}) {
return _then(BridgeError_DnsFailed(
host: null == host ? _self.host : host // ignore: cast_nullable_to_non_nullable
as String,reason: null == reason ? _self.reason : reason // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// @nodoc
class BridgeError_Connection extends BridgeError {
const BridgeError_Connection(this.field0): super._();