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
+220 -17
View File
@@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
default_rust_auto_opaque = RustAutoOpaqueMoi,
);
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1944264248;
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1896742393;
// Section: executor
@@ -187,6 +187,42 @@ fn wire__crate__api__disconnect_impl(
},
)
}
fn wire__crate__api__events_stream_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::<flutter_rust_bridge::for_generated::SseCodec, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "events_stream",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_sink = <StreamSink<
crate::api::BridgeEvent,
flutter_rust_bridge::for_generated::SseCodec,
>>::sse_decode(&mut deserializer);
deserializer.end();
move |context| {
transform_result_sse::<_, crate::BridgeError>((move || {
let output_ok = crate::api::events_stream(api_sink)?;
Ok(output_ok)
})())
}
},
)
}
fn wire__crate__api__is_connected_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@@ -331,6 +367,24 @@ fn wire__crate__api__start_audio_impl(
// Section: dart2rust
impl SseDecode for flutter_rust_bridge::for_generated::anyhow::Error {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut inner = <String>::sse_decode(deserializer);
return flutter_rust_bridge::for_generated::anyhow::anyhow!("{}", inner);
}
}
impl SseDecode
for StreamSink<crate::api::BridgeEvent, flutter_rust_bridge::for_generated::SseCodec>
{
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut inner = <String>::sse_decode(deserializer);
return StreamSink::deserialize(inner);
}
}
impl SseDecode for String {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -400,16 +454,24 @@ impl SseDecode for crate::BridgeError {
return crate::BridgeError::InvalidCommand(var_field0);
}
1 => {
let mut var_host = <String>::sse_decode(deserializer);
let mut var_reason = <String>::sse_decode(deserializer);
return crate::BridgeError::DnsFailed {
host: var_host,
reason: var_reason,
};
}
2 => {
let mut var_field0 = <String>::sse_decode(deserializer);
return crate::BridgeError::Connection(var_field0);
}
2 => {
3 => {
return crate::BridgeError::NotConnected;
}
3 => {
4 => {
return crate::BridgeError::AlreadyConnected;
}
4 => {
5 => {
let mut var_field0 = <String>::sse_decode(deserializer);
return crate::BridgeError::Unmapped(var_field0);
}
@@ -420,6 +482,46 @@ impl SseDecode for crate::BridgeError {
}
}
impl SseDecode for crate::api::BridgeEvent {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut tag_ = <i32>::sse_decode(deserializer);
match tag_ {
0 => {
let mut var_serverName = <String>::sse_decode(deserializer);
return crate::api::BridgeEvent::Connected {
server_name: var_serverName,
};
}
1 => {
let mut var_reason = <String>::sse_decode(deserializer);
return crate::api::BridgeEvent::Lost { reason: var_reason };
}
2 => {
let mut var_attempt = <u32>::sse_decode(deserializer);
let mut var_delaySecs = <u32>::sse_decode(deserializer);
return crate::api::BridgeEvent::Reconnecting {
attempt: var_attempt,
delay_secs: var_delaySecs,
};
}
3 => {
let mut var_reason = <String>::sse_decode(deserializer);
return crate::api::BridgeEvent::Disconnected { reason: var_reason };
}
4 => {
return crate::api::BridgeEvent::AudioStarted;
}
5 => {
return crate::api::BridgeEvent::AudioStopped;
}
_ => {
unimplemented!("");
}
}
}
}
impl SseDecode for crate::api::BridgeSnapshot {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -529,10 +631,11 @@ fn pde_ffi_dispatcher_primary_impl(
2 => wire__crate__api__bridge_init_impl(port, ptr, rust_vec_len, data_len),
3 => wire__crate__api__connect_impl(port, ptr, rust_vec_len, data_len),
4 => wire__crate__api__disconnect_impl(port, ptr, rust_vec_len, data_len),
5 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
6 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
7 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
8 => wire__crate__api__start_audio_impl(port, ptr, rust_vec_len, data_len),
5 => wire__crate__api__events_stream_impl(port, ptr, rust_vec_len, data_len),
6 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
7 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
8 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
9 => wire__crate__api__start_audio_impl(port, ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
@@ -612,13 +715,19 @@ impl flutter_rust_bridge::IntoDart for crate::BridgeError {
crate::BridgeError::InvalidCommand(field0) => {
[0.into_dart(), field0.into_into_dart().into_dart()].into_dart()
}
crate::BridgeError::DnsFailed { host, reason } => [
1.into_dart(),
host.into_into_dart().into_dart(),
reason.into_into_dart().into_dart(),
]
.into_dart(),
crate::BridgeError::Connection(field0) => {
[1.into_dart(), field0.into_into_dart().into_dart()].into_dart()
[2.into_dart(), field0.into_into_dart().into_dart()].into_dart()
}
crate::BridgeError::NotConnected => [2.into_dart()].into_dart(),
crate::BridgeError::AlreadyConnected => [3.into_dart()].into_dart(),
crate::BridgeError::NotConnected => [3.into_dart()].into_dart(),
crate::BridgeError::AlreadyConnected => [4.into_dart()].into_dart(),
crate::BridgeError::Unmapped(field0) => {
[4.into_dart(), field0.into_into_dart().into_dart()].into_dart()
[5.into_dart(), field0.into_into_dart().into_dart()].into_dart()
}
_ => {
unimplemented!("");
@@ -633,6 +742,42 @@ impl flutter_rust_bridge::IntoIntoDart<crate::BridgeError> for crate::BridgeErro
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::api::BridgeEvent {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
match self {
crate::api::BridgeEvent::Connected { server_name } => {
[0.into_dart(), server_name.into_into_dart().into_dart()].into_dart()
}
crate::api::BridgeEvent::Lost { reason } => {
[1.into_dart(), reason.into_into_dart().into_dart()].into_dart()
}
crate::api::BridgeEvent::Reconnecting {
attempt,
delay_secs,
} => [
2.into_dart(),
attempt.into_into_dart().into_dart(),
delay_secs.into_into_dart().into_dart(),
]
.into_dart(),
crate::api::BridgeEvent::Disconnected { reason } => {
[3.into_dart(), reason.into_into_dart().into_dart()].into_dart()
}
crate::api::BridgeEvent::AudioStarted => [4.into_dart()].into_dart(),
crate::api::BridgeEvent::AudioStopped => [5.into_dart()].into_dart(),
_ => {
unimplemented!("");
}
}
}
}
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::api::BridgeEvent {}
impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeEvent> for crate::api::BridgeEvent {
fn into_into_dart(self) -> crate::api::BridgeEvent {
self
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::api::BridgeSnapshot {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
[
@@ -653,6 +798,22 @@ impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeSnapshot> for crate::ap
}
}
impl SseEncode for flutter_rust_bridge::for_generated::anyhow::Error {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<String>::sse_encode(format!("{:?}", self), serializer);
}
}
impl SseEncode
for StreamSink<crate::api::BridgeEvent, flutter_rust_bridge::for_generated::SseCodec>
{
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
unimplemented!("")
}
}
impl SseEncode for String {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
@@ -703,18 +864,23 @@ impl SseEncode for crate::BridgeError {
<i32>::sse_encode(0, serializer);
<String>::sse_encode(field0, serializer);
}
crate::BridgeError::Connection(field0) => {
crate::BridgeError::DnsFailed { host, reason } => {
<i32>::sse_encode(1, serializer);
<String>::sse_encode(host, serializer);
<String>::sse_encode(reason, serializer);
}
crate::BridgeError::Connection(field0) => {
<i32>::sse_encode(2, serializer);
<String>::sse_encode(field0, serializer);
}
crate::BridgeError::NotConnected => {
<i32>::sse_encode(2, serializer);
}
crate::BridgeError::AlreadyConnected => {
<i32>::sse_encode(3, serializer);
}
crate::BridgeError::Unmapped(field0) => {
crate::BridgeError::AlreadyConnected => {
<i32>::sse_encode(4, serializer);
}
crate::BridgeError::Unmapped(field0) => {
<i32>::sse_encode(5, serializer);
<String>::sse_encode(field0, serializer);
}
_ => {
@@ -724,6 +890,43 @@ impl SseEncode for crate::BridgeError {
}
}
impl SseEncode for crate::api::BridgeEvent {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
match self {
crate::api::BridgeEvent::Connected { server_name } => {
<i32>::sse_encode(0, serializer);
<String>::sse_encode(server_name, serializer);
}
crate::api::BridgeEvent::Lost { reason } => {
<i32>::sse_encode(1, serializer);
<String>::sse_encode(reason, serializer);
}
crate::api::BridgeEvent::Reconnecting {
attempt,
delay_secs,
} => {
<i32>::sse_encode(2, serializer);
<u32>::sse_encode(attempt, serializer);
<u32>::sse_encode(delay_secs, serializer);
}
crate::api::BridgeEvent::Disconnected { reason } => {
<i32>::sse_encode(3, serializer);
<String>::sse_encode(reason, serializer);
}
crate::api::BridgeEvent::AudioStarted => {
<i32>::sse_encode(4, serializer);
}
crate::api::BridgeEvent::AudioStopped => {
<i32>::sse_encode(5, serializer);
}
_ => {
unimplemented!("");
}
}
}
}
impl SseEncode for crate::api::BridgeSnapshot {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {