feat: reconnect on transient link loss with per-peer supervisors
Audio links now survive a dropped QUIC connection instead of silently dying until the peer leaves and rejoins the room. Transport: each peer is owned by a single supervisor task running a connect → run → reconnect loop. The deterministic-initiator rule (lower id dials, higher accepts) holds on every reconnect, so one shared connection re-forms each time; the dialer redials with capped backoff and the acceptor awaits the inbound link, switching to a replacement immediately if one arrives before its own close fires. Inbound connections are routed to the supervisor via a per-peer channel. Fixes a latent bug from the prior design: aborting a peer's tasks left the detached send/read loops running, holding Connection clones so the link never actually closed. The loops now live in abort-on-drop guards scoped to the supervisor, so cancelling it releases the connection. UX: a new ConnEvent stream surfaces per-peer link state to the UI, which shows "Connecting…"/"Reconnecting…" with a yellow indicator and border while a peer's audio link is down, returning to normal when it recovers. Tests: the loopback test now runs through the supervisor path, plus a new test drives a real drop (explicit close of a controlled peer endpoint) and asserts the dialer re-dials the stable address and audio resumes over the rebuilt connection. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+31
-4
@@ -10,7 +10,7 @@ use iced::{
|
||||
Color, Background, Border, Element, Subscription, Task, Theme, Event, keyboard,
|
||||
};
|
||||
use iroh::EndpointId;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
@@ -80,6 +80,11 @@ pub struct AppState {
|
||||
peers: HashMap<EndpointId, PeerState>,
|
||||
peer_volumes: HashMap<EndpointId, f32>,
|
||||
audio_levels: HashMap<EndpointId, f32>,
|
||||
/// Peers whose audio link is currently down (initial connect or reconnect).
|
||||
connecting: HashSet<EndpointId>,
|
||||
/// Peers we've had a live link to at least once — used to say "Reconnecting"
|
||||
/// rather than "Connecting" the second time around.
|
||||
ever_connected: HashSet<EndpointId>,
|
||||
controller: Arc<CoreController>,
|
||||
current_screen: Screen,
|
||||
}
|
||||
@@ -121,6 +126,8 @@ impl Default for AppState {
|
||||
peers: HashMap::new(),
|
||||
peer_volumes: HashMap::new(),
|
||||
audio_levels: HashMap::new(),
|
||||
connecting: HashSet::new(),
|
||||
ever_connected: HashSet::new(),
|
||||
controller,
|
||||
current_screen: Screen::Home,
|
||||
}
|
||||
@@ -206,6 +213,8 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
state.ticket = "".to_string();
|
||||
state.peers.clear();
|
||||
state.audio_levels.clear();
|
||||
state.connecting.clear();
|
||||
state.ever_connected.clear();
|
||||
state.status_message = "Ready to connect".to_string();
|
||||
state.current_screen = Screen::Home;
|
||||
}
|
||||
@@ -215,10 +224,19 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
UiEvent::PeerLeft { id } => {
|
||||
state.peers.remove(&id);
|
||||
state.audio_levels.remove(&id);
|
||||
state.connecting.remove(&id);
|
||||
state.ever_connected.remove(&id);
|
||||
}
|
||||
UiEvent::PeerUpdated { id, state: peer_state } => {
|
||||
state.peers.insert(id, peer_state);
|
||||
}
|
||||
UiEvent::PeerConnecting { id } => {
|
||||
state.connecting.insert(id);
|
||||
}
|
||||
UiEvent::PeerConnected { id } => {
|
||||
state.connecting.remove(&id);
|
||||
state.ever_connected.insert(id);
|
||||
}
|
||||
UiEvent::AudioLevels(levels) => {
|
||||
for (id, val) in levels {
|
||||
state.audio_levels.insert(id, val);
|
||||
@@ -326,6 +344,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
let color_red = Color::from_rgb8(243, 139, 168);
|
||||
let color_maroon = Color::from_rgb8(233, 146, 160);
|
||||
let color_green = Color::from_rgb8(166, 227, 161);
|
||||
let color_yellow = Color::from_rgb8(249, 226, 175);
|
||||
|
||||
// Style Helpers
|
||||
let c_style = move |bg: Color, b_color: Color, radius: f32| {
|
||||
@@ -585,9 +604,17 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
|
||||
for (peer_id, peer) in &state.peers {
|
||||
let level = state.audio_levels.get(peer_id).copied().unwrap_or(0.0);
|
||||
let is_speaking = level > 0.01;
|
||||
let is_connecting = state.connecting.contains(peer_id);
|
||||
let is_speaking = !is_connecting && level > 0.01;
|
||||
|
||||
let indicator = if peer.is_muted {
|
||||
let indicator = if is_connecting {
|
||||
let label = if state.ever_connected.contains(peer_id) {
|
||||
"[Reconnecting…]"
|
||||
} else {
|
||||
"[Connecting…]"
|
||||
};
|
||||
text(label).size(14).color(color_yellow)
|
||||
} else if peer.is_muted {
|
||||
text("[Muted]").size(14).color(color_red)
|
||||
} else if is_speaking {
|
||||
text("[Speaking]").size(14).color(color_green)
|
||||
@@ -620,7 +647,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
let card = container(card_content)
|
||||
.style(c_style(
|
||||
if is_speaking { color_base } else { color_mantle },
|
||||
if is_speaking { color_green } else { color_surface },
|
||||
if is_connecting { color_yellow } else if is_speaking { color_green } else { color_surface },
|
||||
6.0
|
||||
))
|
||||
.padding(12);
|
||||
|
||||
Reference in New Issue
Block a user