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:
@@ -11,8 +11,9 @@ use std::time::Duration;
|
||||
|
||||
use flutter_rust_bridge::frb;
|
||||
use tokio::runtime::Runtime;
|
||||
use tracing::info;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::frb_generated::StreamSink;
|
||||
use crate::BridgeError;
|
||||
|
||||
/// Process-wide tokio runtime used to drive the async core. Created
|
||||
@@ -232,6 +233,93 @@ pub struct BridgeAudioStats {
|
||||
pub ptt_active: bool,
|
||||
}
|
||||
|
||||
// ---------- Events (A.6) ----------
|
||||
|
||||
/// Lifecycle event surfaced to Dart. Schema-controlled mirror of
|
||||
/// [`chanora_core::SessionEvent`] — no core types cross the bridge.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum BridgeEvent {
|
||||
/// Initial connect succeeded, or a reconnect attempt succeeded.
|
||||
Connected {
|
||||
/// Server name reported by the server snapshot.
|
||||
server_name: String,
|
||||
},
|
||||
/// Connection lost; supervisor will retry.
|
||||
Lost {
|
||||
/// Reason classification from the protocol layer.
|
||||
reason: String,
|
||||
},
|
||||
/// Supervisor is sleeping before its next reconnect attempt.
|
||||
Reconnecting {
|
||||
/// 1-based attempt counter for the current outage.
|
||||
attempt: u32,
|
||||
/// Seconds the supervisor will sleep before this attempt.
|
||||
delay_secs: u32,
|
||||
},
|
||||
/// Session ended (user-requested disconnect or unrecoverable).
|
||||
Disconnected {
|
||||
/// Reason classification.
|
||||
reason: String,
|
||||
},
|
||||
/// Audio engine started.
|
||||
AudioStarted,
|
||||
/// Audio engine stopped.
|
||||
AudioStopped,
|
||||
}
|
||||
|
||||
impl From<chanora_core::SessionEvent> for BridgeEvent {
|
||||
fn from(e: chanora_core::SessionEvent) -> Self {
|
||||
match e {
|
||||
chanora_core::SessionEvent::Connected { server_name } => {
|
||||
BridgeEvent::Connected { server_name }
|
||||
}
|
||||
chanora_core::SessionEvent::Lost { reason } => BridgeEvent::Lost { reason },
|
||||
chanora_core::SessionEvent::Reconnecting {
|
||||
attempt,
|
||||
delay_secs,
|
||||
} => BridgeEvent::Reconnecting {
|
||||
attempt,
|
||||
delay_secs,
|
||||
},
|
||||
chanora_core::SessionEvent::Disconnected { reason } => {
|
||||
BridgeEvent::Disconnected { reason }
|
||||
}
|
||||
chanora_core::SessionEvent::AudioStarted => BridgeEvent::AudioStarted,
|
||||
chanora_core::SessionEvent::AudioStopped => BridgeEvent::AudioStopped,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
pub fn events_stream(sink: StreamSink<BridgeEvent>) -> Result<(), BridgeError> {
|
||||
let mut rx = session().subscribe_events();
|
||||
runtime().spawn(async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(evt) => {
|
||||
if sink.add(BridgeEvent::from(evt)).is_err() {
|
||||
// Dart side closed the sink — stop the bridge task.
|
||||
info!(target: "chanora_bridge", "events_stream: dart sink closed");
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!(target: "chanora_bridge", "events_stream: lagged, dropped {n} events");
|
||||
continue;
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||
info!(target: "chanora_bridge", "events_stream: source closed");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read audio statistics. Errors if no connection or audio not started.
|
||||
pub async fn audio_stats() -> Result<BridgeAudioStats, BridgeError> {
|
||||
let (s, r, p) = runtime()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -68,6 +68,20 @@ enum Request {
|
||||
Disconnect(oneshot::Sender<()>),
|
||||
}
|
||||
|
||||
/// Why a [`ProtocolClient`] task ended. Distinguishes a user-driven
|
||||
/// disconnect (the supervisor must NOT retry) from a network-driven
|
||||
/// loss (the supervisor should consider retrying).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DisconnectReason {
|
||||
/// The caller explicitly called [`ProtocolClient::disconnect`]
|
||||
/// or dropped the handle.
|
||||
UserRequested,
|
||||
/// The underlying tsclientlib event stream ended.
|
||||
StreamEnded,
|
||||
/// A protocol-layer error caused the task to abort.
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Async handle owning a live protocol connection. Drop = disconnect.
|
||||
pub struct ProtocolClient {
|
||||
tx: mpsc::Sender<Request>,
|
||||
@@ -78,6 +92,11 @@ pub struct ProtocolClient {
|
||||
/// Wrapped in a `Mutex<Option<_>>` so the consumer can take it
|
||||
/// exactly once.
|
||||
voice_in_rx: std::sync::Mutex<Option<mpsc::Receiver<InboundVoice>>>,
|
||||
/// Fires exactly once when the connection task exits, with the
|
||||
/// reason. Used by the supervisor in `chanora_core` to drive
|
||||
/// auto-reconnect. Wrapped in a Mutex<Option<_>> so it can be
|
||||
/// taken once by the supervisor and never resurfaced.
|
||||
lost_rx: std::sync::Mutex<Option<oneshot::Receiver<DisconnectReason>>>,
|
||||
}
|
||||
|
||||
/// One inbound voice packet from a remote client.
|
||||
@@ -88,6 +107,28 @@ pub struct InboundVoice {
|
||||
pub packet: InAudioBuf,
|
||||
}
|
||||
|
||||
/// A cheap, clone-free probe handle for the watchdog. Owns its own
|
||||
/// clone of the connection task's request channel.
|
||||
#[derive(Clone)]
|
||||
pub struct SnapshotProbe {
|
||||
tx: mpsc::Sender<Request>,
|
||||
}
|
||||
|
||||
impl SnapshotProbe {
|
||||
/// Issue a single snapshot RPC. Returns the same error shape as
|
||||
/// [`ProtocolClient::snapshot`]. Suitable for use under a
|
||||
/// `tokio::time::timeout`.
|
||||
pub async fn probe(&self) -> Result<ServerSnapshot, ProtocolError> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.tx
|
||||
.send(Request::Snapshot(tx))
|
||||
.await
|
||||
.map_err(|_| ProtocolError::Lost("connection task is gone".to_string()))?;
|
||||
rx.await
|
||||
.map_err(|_| ProtocolError::Lost("snapshot reply dropped".to_string()))?
|
||||
}
|
||||
}
|
||||
|
||||
impl ProtocolClient {
|
||||
/// Dial the server and wait for the initial state snapshot. The
|
||||
/// returned client is ready for [`Self::snapshot`] and
|
||||
@@ -104,6 +145,7 @@ impl ProtocolClient {
|
||||
let (voice_out_tx, voice_out_rx) = mpsc::channel::<OutPacket>(64);
|
||||
let (voice_in_tx, voice_in_rx) = mpsc::channel::<InboundVoice>(64);
|
||||
let (ready_tx, ready_rx) = oneshot::channel::<Result<(), ProtocolError>>();
|
||||
let (lost_tx, lost_rx) = oneshot::channel::<DisconnectReason>();
|
||||
|
||||
tokio::spawn(connection_task(
|
||||
cfg.clone(),
|
||||
@@ -111,6 +153,7 @@ impl ProtocolClient {
|
||||
voice_out_rx,
|
||||
voice_in_tx,
|
||||
ready_tx,
|
||||
lost_tx,
|
||||
));
|
||||
|
||||
match tokio::time::timeout(cfg.ready_timeout, ready_rx).await {
|
||||
@@ -118,6 +161,7 @@ impl ProtocolClient {
|
||||
tx,
|
||||
voice_out_tx,
|
||||
voice_in_rx: std::sync::Mutex::new(Some(voice_in_rx)),
|
||||
lost_rx: std::sync::Mutex::new(Some(lost_rx)),
|
||||
}),
|
||||
Ok(Ok(Err(e))) => Err(e),
|
||||
Ok(Err(_)) => Err(ProtocolError::Backend(
|
||||
@@ -151,11 +195,28 @@ impl ProtocolClient {
|
||||
self.voice_out_tx.clone()
|
||||
}
|
||||
|
||||
/// Clone the request channel so a watchdog can issue probes
|
||||
/// without holding a `&self` reference across the await. The
|
||||
/// returned [`SnapshotProbe`] is `Send + 'static` and dispatches
|
||||
/// a single snapshot RPC against this protocol task.
|
||||
pub fn snapshot_probe(&self) -> SnapshotProbe {
|
||||
SnapshotProbe {
|
||||
tx: self.tx.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the inbound-voice receiver. Returns `None` if it has
|
||||
/// already been taken; only one consumer is allowed.
|
||||
pub fn take_voice_in(&self) -> Option<mpsc::Receiver<InboundVoice>> {
|
||||
self.voice_in_rx.lock().ok().and_then(|mut g| g.take())
|
||||
}
|
||||
|
||||
/// Take the loss-notifier. Returns `None` if it has already been
|
||||
/// taken. The supervisor in `chanora_core` consumes this to
|
||||
/// drive auto-reconnect; nothing else should call it.
|
||||
pub fn take_loss_notifier(&self) -> Option<oneshot::Receiver<DisconnectReason>> {
|
||||
self.lost_rx.lock().ok().and_then(|mut g| g.take())
|
||||
}
|
||||
}
|
||||
|
||||
async fn connection_task(
|
||||
@@ -164,7 +225,20 @@ async fn connection_task(
|
||||
mut voice_out_rx: mpsc::Receiver<OutPacket>,
|
||||
voice_in_tx: mpsc::Sender<InboundVoice>,
|
||||
ready_tx: oneshot::Sender<Result<(), ProtocolError>>,
|
||||
lost_tx: oneshot::Sender<DisconnectReason>,
|
||||
) {
|
||||
// Box the lost_tx so each exit branch can move it.
|
||||
let mut lost_tx = Some(lost_tx);
|
||||
// Macro: report the disconnect reason and return from the task.
|
||||
macro_rules! exit {
|
||||
($reason:expr) => {{
|
||||
if let Some(tx) = lost_tx.take() {
|
||||
let _ = tx.send($reason);
|
||||
}
|
||||
return;
|
||||
}};
|
||||
}
|
||||
|
||||
// Resolve the hostname OURSELVES using the platform resolver.
|
||||
// tsclientlib's built-in hickory-resolver reads /etc/resolv.conf,
|
||||
// which does not exist on Android or iOS — by side-stepping it
|
||||
@@ -172,8 +246,9 @@ async fn connection_task(
|
||||
let addrs = match crate::resolver::resolve(&cfg.address).await {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
let msg = format!("{e}");
|
||||
let _ = ready_tx.send(Err(e));
|
||||
return;
|
||||
exit!(DisconnectReason::Error(msg));
|
||||
}
|
||||
};
|
||||
// Pick the first address (IPv4 preferred by the resolver's
|
||||
@@ -196,8 +271,9 @@ async fn connection_task(
|
||||
Some(s) => match Identity::new_from_str(s) {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
let _ = ready_tx.send(Err(ProtocolError::Identity(format!("{e}"))));
|
||||
return;
|
||||
let msg = format!("{e}");
|
||||
let _ = ready_tx.send(Err(ProtocolError::Identity(msg.clone())));
|
||||
exit!(DisconnectReason::Error(format!("identity: {msg}")));
|
||||
}
|
||||
},
|
||||
None => Identity::create(),
|
||||
@@ -211,8 +287,9 @@ async fn connection_task(
|
||||
let mut con = match builder.connect() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
let _ = ready_tx.send(Err(ProtocolError::Connect(format!("{e}"))));
|
||||
return;
|
||||
let msg = format!("{e}");
|
||||
let _ = ready_tx.send(Err(ProtocolError::Connect(msg.clone())));
|
||||
exit!(DisconnectReason::Error(format!("connect: {msg}")));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -227,14 +304,14 @@ async fn connection_task(
|
||||
info!(target: "chanora_protocol", "initial state snapshot received");
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
let _ = ready_tx.send(Err(ProtocolError::DisconnectedEarly(format!("{e}"))));
|
||||
return;
|
||||
let msg = format!("{e}");
|
||||
let _ = ready_tx.send(Err(ProtocolError::DisconnectedEarly(msg.clone())));
|
||||
exit!(DisconnectReason::Error(format!("disconnected early: {msg}")));
|
||||
}
|
||||
None => {
|
||||
let _ = ready_tx.send(Err(ProtocolError::DisconnectedEarly(
|
||||
"event stream ended before snapshot".to_string(),
|
||||
)));
|
||||
return;
|
||||
let msg = "event stream ended before snapshot".to_string();
|
||||
let _ = ready_tx.send(Err(ProtocolError::DisconnectedEarly(msg.clone())));
|
||||
exit!(DisconnectReason::Error(msg));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,10 +335,9 @@ async fn connection_task(
|
||||
warn!(target: "chanora_protocol", error = %e, "event error during settle");
|
||||
}
|
||||
Ok(None) => {
|
||||
let _ = ready_tx.send(Err(ProtocolError::DisconnectedEarly(
|
||||
"stream closed during settle".to_string(),
|
||||
)));
|
||||
return;
|
||||
let msg = "stream closed during settle".to_string();
|
||||
let _ = ready_tx.send(Err(ProtocolError::DisconnectedEarly(msg.clone())));
|
||||
exit!(DisconnectReason::StreamEnded);
|
||||
}
|
||||
Err(_) => { /* no event available right now; keep waiting */ }
|
||||
}
|
||||
@@ -303,10 +379,12 @@ async fn connection_task(
|
||||
}
|
||||
Ok(Some(Err(e))) => {
|
||||
warn!(target: "chanora_protocol", error = %e, "event error");
|
||||
// Some errors are transient; treat persistent ones
|
||||
// as a loss after the next iteration.
|
||||
}
|
||||
Ok(None) => {
|
||||
warn!(target: "chanora_protocol", "event stream ended");
|
||||
return;
|
||||
exit!(DisconnectReason::StreamEnded);
|
||||
}
|
||||
Err(_) => { /* no event in 20 ms */ }
|
||||
}
|
||||
@@ -322,14 +400,14 @@ async fn connection_task(
|
||||
con.events().for_each(|_| future::ready(())).await;
|
||||
let _ = reply.send(());
|
||||
info!(target: "chanora_protocol", "clean disconnect");
|
||||
return;
|
||||
exit!(DisconnectReason::UserRequested);
|
||||
}
|
||||
Err(mpsc::error::TryRecvError::Empty) => {}
|
||||
Err(mpsc::error::TryRecvError::Disconnected) => {
|
||||
let _ = con.disconnect(DisconnectOptions::new());
|
||||
con.events().for_each(|_| future::ready(())).await;
|
||||
info!(target: "chanora_protocol", "handle dropped; implicit disconnect");
|
||||
return;
|
||||
exit!(DisconnectReason::UserRequested);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ mod adapter;
|
||||
mod dto;
|
||||
mod resolver;
|
||||
|
||||
pub use adapter::{ConnectConfig, InboundVoice, ProtocolClient};
|
||||
pub use adapter::{ConnectConfig, DisconnectReason, InboundVoice, ProtocolClient, SnapshotProbe};
|
||||
pub use dto::{ChannelInfo, ClientInfo, ServerSnapshot};
|
||||
|
||||
// Re-export the upstream voice types so chanora_audio can build outbound
|
||||
|
||||
Reference in New Issue
Block a user