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:
2026-05-29 17:01:21 -04:00
co-authored by Claude Opus 4.8
parent 26c3758d0d
commit ccf2bff87c
6 changed files with 477 additions and 81 deletions
+31 -4
View File
@@ -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);