feat(events): A.4 — emit SnapshotChanged from the watchdog probe

Adds a new variant to the lifecycle event catalogue so the UI can
auto-refresh the channel/client tree without an independent polling
timer on the Dart side. The supervisor's existing 5 s snapshot probe
is the source of truth: it already pulls a full snapshot to keep
the watchdog honest, so we piggyback on it.

* `chanora_core::SessionEvent::SnapshotChanged { channels, clients }`
  carries the latest channel and client counts.
* The supervisor compares the probe result to `last_counts` and
  fires the event only when the count actually changes. `last_counts`
  is reset to `None` on a successful reconnect so the freshly
  dialled session re-emits its initial counts.
* `chanora_bridge::api::BridgeEvent::SnapshotChanged` is the
  cross-bridge mirror.
* Flutter routes the event through `_onEvent`, which calls
  `_onRefresh()` to repopulate the snapshot view.

The probe-driven detection has known limits — pure within-channel
client moves do not change the count and so are not surfaced. That
gap will close when the supervisor tracks a content hash in
addition to the count; the count-only signal is sufficient for the
common "someone joined / someone left" case observed on cn.teamspeak.app.
This commit is contained in:
EdisonJwa
2026-05-15 01:27:57 +08:00
parent d2d9ba0a5b
commit 43a3c9ba76
7 changed files with 184 additions and 13 deletions
+6
View File
@@ -154,6 +154,12 @@ class _BetaHomeState extends State<_BetaHome> {
setState(() => _audioStarted = true);
case rust.BridgeEvent_AudioStopped():
setState(() => _audioStarted = false);
case rust.BridgeEvent_SnapshotChanged():
// A.4: the supervisor's watchdog observed a tree change.
// Trigger a single async refresh so the channel/client
// list stays current without a polling timer on the Dart
// side.
unawaited(_onRefresh());
}
}
@@ -211,6 +211,17 @@ sealed class BridgeEvent with _$BridgeEvent {
/// Audio engine stopped.
const factory BridgeEvent.audioStopped() = BridgeEvent_AudioStopped;
/// Snapshot probe observed a change in channel/client counts.
/// UI uses this to drive an auto-refresh without active
/// polling.
const factory BridgeEvent.snapshotChanged({
/// Latest channel count.
required int channels,
/// Latest client count.
required int clients,
}) = BridgeEvent_SnapshotChanged;
}
/// Coarse OS-reported network state. Mirrors
@@ -55,7 +55,7 @@ extension BridgeEventPatterns on BridgeEvent {
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>({TResult Function( BridgeEvent_Connected value)? connected,TResult Function( BridgeEvent_Lost value)? lost,TResult Function( BridgeEvent_Reconnecting value)? reconnecting,TResult Function( BridgeEvent_Disconnected value)? disconnected,TResult Function( BridgeEvent_AudioStarted value)? audioStarted,TResult Function( BridgeEvent_AudioStopped value)? audioStopped,required TResult orElse(),}){
@optionalTypeArgs TResult maybeMap<TResult extends Object?>({TResult Function( BridgeEvent_Connected value)? connected,TResult Function( BridgeEvent_Lost value)? lost,TResult Function( BridgeEvent_Reconnecting value)? reconnecting,TResult Function( BridgeEvent_Disconnected value)? disconnected,TResult Function( BridgeEvent_AudioStarted value)? audioStarted,TResult Function( BridgeEvent_AudioStopped value)? audioStopped,TResult Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,required TResult orElse(),}){
final _that = this;
switch (_that) {
case BridgeEvent_Connected() when connected != null:
@@ -64,7 +64,8 @@ return lost(_that);case BridgeEvent_Reconnecting() when reconnecting != null:
return reconnecting(_that);case BridgeEvent_Disconnected() when disconnected != null:
return disconnected(_that);case BridgeEvent_AudioStarted() when audioStarted != null:
return audioStarted(_that);case BridgeEvent_AudioStopped() when audioStopped != null:
return audioStopped(_that);case _:
return audioStopped(_that);case BridgeEvent_SnapshotChanged() when snapshotChanged != null:
return snapshotChanged(_that);case _:
return orElse();
}
@@ -82,7 +83,7 @@ return audioStopped(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>({required TResult Function( BridgeEvent_Connected value) connected,required TResult Function( BridgeEvent_Lost value) lost,required TResult Function( BridgeEvent_Reconnecting value) reconnecting,required TResult Function( BridgeEvent_Disconnected value) disconnected,required TResult Function( BridgeEvent_AudioStarted value) audioStarted,required TResult Function( BridgeEvent_AudioStopped value) audioStopped,}){
@optionalTypeArgs TResult map<TResult extends Object?>({required TResult Function( BridgeEvent_Connected value) connected,required TResult Function( BridgeEvent_Lost value) lost,required TResult Function( BridgeEvent_Reconnecting value) reconnecting,required TResult Function( BridgeEvent_Disconnected value) disconnected,required TResult Function( BridgeEvent_AudioStarted value) audioStarted,required TResult Function( BridgeEvent_AudioStopped value) audioStopped,required TResult Function( BridgeEvent_SnapshotChanged value) snapshotChanged,}){
final _that = this;
switch (_that) {
case BridgeEvent_Connected():
@@ -91,7 +92,8 @@ return lost(_that);case BridgeEvent_Reconnecting():
return reconnecting(_that);case BridgeEvent_Disconnected():
return disconnected(_that);case BridgeEvent_AudioStarted():
return audioStarted(_that);case BridgeEvent_AudioStopped():
return audioStopped(_that);}
return audioStopped(_that);case BridgeEvent_SnapshotChanged():
return snapshotChanged(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
@@ -105,7 +107,7 @@ return audioStopped(_that);}
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>({TResult? Function( BridgeEvent_Connected value)? connected,TResult? Function( BridgeEvent_Lost value)? lost,TResult? Function( BridgeEvent_Reconnecting value)? reconnecting,TResult? Function( BridgeEvent_Disconnected value)? disconnected,TResult? Function( BridgeEvent_AudioStarted value)? audioStarted,TResult? Function( BridgeEvent_AudioStopped value)? audioStopped,}){
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>({TResult? Function( BridgeEvent_Connected value)? connected,TResult? Function( BridgeEvent_Lost value)? lost,TResult? Function( BridgeEvent_Reconnecting value)? reconnecting,TResult? Function( BridgeEvent_Disconnected value)? disconnected,TResult? Function( BridgeEvent_AudioStarted value)? audioStarted,TResult? Function( BridgeEvent_AudioStopped value)? audioStopped,TResult? Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,}){
final _that = this;
switch (_that) {
case BridgeEvent_Connected() when connected != null:
@@ -114,7 +116,8 @@ return lost(_that);case BridgeEvent_Reconnecting() when reconnecting != null:
return reconnecting(_that);case BridgeEvent_Disconnected() when disconnected != null:
return disconnected(_that);case BridgeEvent_AudioStarted() when audioStarted != null:
return audioStarted(_that);case BridgeEvent_AudioStopped() when audioStopped != null:
return audioStopped(_that);case _:
return audioStopped(_that);case BridgeEvent_SnapshotChanged() when snapshotChanged != null:
return snapshotChanged(_that);case _:
return null;
}
@@ -131,7 +134,7 @@ return audioStopped(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function( String serverName)? connected,TResult Function( String reason)? lost,TResult Function( int attempt, int delaySecs)? reconnecting,TResult Function( String reason)? disconnected,TResult Function()? audioStarted,TResult Function()? audioStopped,required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function( String serverName)? connected,TResult Function( String reason)? lost,TResult Function( int attempt, int delaySecs)? reconnecting,TResult Function( String reason)? disconnected,TResult Function()? audioStarted,TResult Function()? audioStopped,TResult Function( int channels, int clients)? snapshotChanged,required TResult orElse(),}) {final _that = this;
switch (_that) {
case BridgeEvent_Connected() when connected != null:
return connected(_that.serverName);case BridgeEvent_Lost() when lost != null:
@@ -139,7 +142,8 @@ return lost(_that.reason);case BridgeEvent_Reconnecting() when reconnecting != n
return reconnecting(_that.attempt,_that.delaySecs);case BridgeEvent_Disconnected() when disconnected != null:
return disconnected(_that.reason);case BridgeEvent_AudioStarted() when audioStarted != null:
return audioStarted();case BridgeEvent_AudioStopped() when audioStopped != null:
return audioStopped();case _:
return audioStopped();case BridgeEvent_SnapshotChanged() when snapshotChanged != null:
return snapshotChanged(_that.channels,_that.clients);case _:
return orElse();
}
@@ -157,7 +161,7 @@ return audioStopped();case _:
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function( String serverName) connected,required TResult Function( String reason) lost,required TResult Function( int attempt, int delaySecs) reconnecting,required TResult Function( String reason) disconnected,required TResult Function() audioStarted,required TResult Function() audioStopped,}) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function( String serverName) connected,required TResult Function( String reason) lost,required TResult Function( int attempt, int delaySecs) reconnecting,required TResult Function( String reason) disconnected,required TResult Function() audioStarted,required TResult Function() audioStopped,required TResult Function( int channels, int clients) snapshotChanged,}) {final _that = this;
switch (_that) {
case BridgeEvent_Connected():
return connected(_that.serverName);case BridgeEvent_Lost():
@@ -165,7 +169,8 @@ return lost(_that.reason);case BridgeEvent_Reconnecting():
return reconnecting(_that.attempt,_that.delaySecs);case BridgeEvent_Disconnected():
return disconnected(_that.reason);case BridgeEvent_AudioStarted():
return audioStarted();case BridgeEvent_AudioStopped():
return audioStopped();}
return audioStopped();case BridgeEvent_SnapshotChanged():
return snapshotChanged(_that.channels,_that.clients);}
}
/// A variant of `when` that fallback to returning `null`
///
@@ -179,7 +184,7 @@ return audioStopped();}
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function( String serverName)? connected,TResult? Function( String reason)? lost,TResult? Function( int attempt, int delaySecs)? reconnecting,TResult? Function( String reason)? disconnected,TResult? Function()? audioStarted,TResult? Function()? audioStopped,}) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function( String serverName)? connected,TResult? Function( String reason)? lost,TResult? Function( int attempt, int delaySecs)? reconnecting,TResult? Function( String reason)? disconnected,TResult? Function()? audioStarted,TResult? Function()? audioStopped,TResult? Function( int channels, int clients)? snapshotChanged,}) {final _that = this;
switch (_that) {
case BridgeEvent_Connected() when connected != null:
return connected(_that.serverName);case BridgeEvent_Lost() when lost != null:
@@ -187,7 +192,8 @@ return lost(_that.reason);case BridgeEvent_Reconnecting() when reconnecting != n
return reconnecting(_that.attempt,_that.delaySecs);case BridgeEvent_Disconnected() when disconnected != null:
return disconnected(_that.reason);case BridgeEvent_AudioStarted() when audioStarted != null:
return audioStarted();case BridgeEvent_AudioStopped() when audioStopped != null:
return audioStopped();case _:
return audioStopped();case BridgeEvent_SnapshotChanged() when snapshotChanged != null:
return snapshotChanged(_that.channels,_that.clients);case _:
return null;
}
@@ -530,4 +536,74 @@ String toString() {
/// @nodoc
class BridgeEvent_SnapshotChanged extends BridgeEvent {
const BridgeEvent_SnapshotChanged({required this.channels, required this.clients}): super._();
/// Latest channel count.
final int channels;
/// Latest client count.
final int clients;
/// Create a copy of BridgeEvent
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$BridgeEvent_SnapshotChangedCopyWith<BridgeEvent_SnapshotChanged> get copyWith => _$BridgeEvent_SnapshotChangedCopyWithImpl<BridgeEvent_SnapshotChanged>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeEvent_SnapshotChanged&&(identical(other.channels, channels) || other.channels == channels)&&(identical(other.clients, clients) || other.clients == clients));
}
@override
int get hashCode => Object.hash(runtimeType,channels,clients);
@override
String toString() {
return 'BridgeEvent.snapshotChanged(channels: $channels, clients: $clients)';
}
}
/// @nodoc
abstract mixin class $BridgeEvent_SnapshotChangedCopyWith<$Res> implements $BridgeEventCopyWith<$Res> {
factory $BridgeEvent_SnapshotChangedCopyWith(BridgeEvent_SnapshotChanged value, $Res Function(BridgeEvent_SnapshotChanged) _then) = _$BridgeEvent_SnapshotChangedCopyWithImpl;
@useResult
$Res call({
int channels, int clients
});
}
/// @nodoc
class _$BridgeEvent_SnapshotChangedCopyWithImpl<$Res>
implements $BridgeEvent_SnapshotChangedCopyWith<$Res> {
_$BridgeEvent_SnapshotChangedCopyWithImpl(this._self, this._then);
final BridgeEvent_SnapshotChanged _self;
final $Res Function(BridgeEvent_SnapshotChanged) _then;
/// Create a copy of BridgeEvent
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') $Res call({Object? channels = null,Object? clients = null,}) {
return _then(BridgeEvent_SnapshotChanged(
channels: null == channels ? _self.channels : channels // ignore: cast_nullable_to_non_nullable
as int,clients: null == clients ? _self.clients : clients // ignore: cast_nullable_to_non_nullable
as int,
));
}
}
// dart format on
@@ -551,6 +551,11 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return BridgeEvent_AudioStarted();
case 5:
return BridgeEvent_AudioStopped();
case 6:
return BridgeEvent_SnapshotChanged(
channels: dco_decode_u_32(raw[1]),
clients: dco_decode_u_32(raw[2]),
);
default:
throw Exception("unreachable");
}
@@ -751,6 +756,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return BridgeEvent_AudioStarted();
case 5:
return BridgeEvent_AudioStopped();
case 6:
var var_channels = sse_decode_u_32(deserializer);
var var_clients = sse_decode_u_32(deserializer);
return BridgeEvent_SnapshotChanged(
channels: var_channels,
clients: var_clients,
);
default:
throw UnimplementedError('');
}
@@ -968,6 +980,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_i_32(4, serializer);
case BridgeEvent_AudioStopped():
sse_encode_i_32(5, serializer);
case BridgeEvent_SnapshotChanged(
channels: final channels,
clients: final clients,
):
sse_encode_i_32(6, serializer);
sse_encode_u_32(channels, serializer);
sse_encode_u_32(clients, serializer);
}
}
+29 -1
View File
@@ -123,6 +123,16 @@ pub enum SessionEvent {
/// Audio engine stopped (e.g. before a reconnect cycle, or by
/// explicit user action).
AudioStopped,
/// A snapshot probe observed a change in channel or client
/// counts. Useful for UI auto-refresh without active polling
/// from the Dart side. Carries the counts so subscribers can
/// decide whether to re-fetch.
SnapshotChanged {
/// Number of channels in the latest probe snapshot.
channels: u32,
/// Number of clients in the latest probe snapshot.
clients: u32,
},
}
/// Coarse OS-reported network state. Populated by the Flutter side
@@ -449,6 +459,10 @@ async fn supervisor_loop(
let mut lost_rx = initial_lost_rx;
let mut probe = initial_probe;
let mut cfg = initial_cfg;
// Last snapshot signature observed by the watchdog. Used to
// emit `SessionEvent::SnapshotChanged` only when the channel
// or client counts actually change.
let mut last_counts: Option<(u32, u32)> = None;
loop {
// Watch the current connection: race the protocol task's
@@ -515,7 +529,7 @@ async fn supervisor_loop(
}
_ = watchdog.tick() => {
match tokio::time::timeout(WATCHDOG_PROBE_TIMEOUT, probe.probe()).await {
Ok(Ok(_)) => {
Ok(Ok(snap)) => {
if misses > 0 {
info!(
target: "chanora_core",
@@ -524,6 +538,17 @@ async fn supervisor_loop(
);
}
misses = 0;
// A.4 SnapshotChanged: emit when the
// channel or client count differs from
// the previously observed snapshot.
let counts = (snap.channels.len() as u32, snap.clients.len() as u32);
if last_counts != Some(counts) {
last_counts = Some(counts);
let _ = events_tx.send(SessionEvent::SnapshotChanged {
channels: counts.0,
clients: counts.1,
});
}
}
Ok(Err(e)) => {
misses = misses.saturating_add(1);
@@ -725,6 +750,9 @@ async fn supervisor_loop(
// Loop back to waiting for the next loss.
lost_rx = new_lost_rx;
probe = new_probe;
// Force re-emission of SnapshotChanged
// for the freshly reconnected session.
last_counts = None;
break;
}
Err(e) => {
+12
View File
@@ -359,6 +359,15 @@ pub enum BridgeEvent {
AudioStarted,
/// Audio engine stopped.
AudioStopped,
/// Snapshot probe observed a change in channel/client counts.
/// UI uses this to drive an auto-refresh without active
/// polling.
SnapshotChanged {
/// Latest channel count.
channels: u32,
/// Latest client count.
clients: u32,
},
}
impl From<chanora_core::SessionEvent> for BridgeEvent {
@@ -380,6 +389,9 @@ impl From<chanora_core::SessionEvent> for BridgeEvent {
}
chanora_core::SessionEvent::AudioStarted => BridgeEvent::AudioStarted,
chanora_core::SessionEvent::AudioStopped => BridgeEvent::AudioStopped,
chanora_core::SessionEvent::SnapshotChanged { channels, clients } => {
BridgeEvent::SnapshotChanged { channels, clients }
}
}
}
}
@@ -612,6 +612,14 @@ impl SseDecode for crate::api::BridgeEvent {
5 => {
return crate::api::BridgeEvent::AudioStopped;
}
6 => {
let mut var_channels = <u32>::sse_decode(deserializer);
let mut var_clients = <u32>::sse_decode(deserializer);
return crate::api::BridgeEvent::SnapshotChanged {
channels: var_channels,
clients: var_clients,
};
}
_ => {
unimplemented!("");
}
@@ -878,6 +886,12 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeEvent {
}
crate::api::BridgeEvent::AudioStarted => [4.into_dart()].into_dart(),
crate::api::BridgeEvent::AudioStopped => [5.into_dart()].into_dart(),
crate::api::BridgeEvent::SnapshotChanged { channels, clients } => [
6.into_dart(),
channels.into_into_dart().into_dart(),
clients.into_into_dart().into_dart(),
]
.into_dart(),
_ => {
unimplemented!("");
}
@@ -1055,6 +1069,11 @@ impl SseEncode for crate::api::BridgeEvent {
crate::api::BridgeEvent::AudioStopped => {
<i32>::sse_encode(5, serializer);
}
crate::api::BridgeEvent::SnapshotChanged { channels, clients } => {
<i32>::sse_encode(6, serializer);
<u32>::sse_encode(channels, serializer);
<u32>::sse_encode(clients, serializer);
}
_ => {
unimplemented!("");
}