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
+15
View File
@@ -36,6 +36,18 @@ pub enum RoomEvent {
PeerUpdated(EndpointId, PeerState),
}
/// Transport-level link state for a peer, surfaced so the UI can show when a
/// peer's audio connection is being (re)established versus actually carrying
/// audio. Distinct from `RoomEvent`: a peer can be present in the gossip room
/// while its audio link is momentarily down and reconnecting.
#[derive(Debug, Clone)]
pub enum ConnEvent {
/// No live audio link yet — initial connect or reconnecting after a drop.
Connecting(EndpointId),
/// A live audio link is established and carrying datagrams.
Connected(EndpointId),
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct PeerSpeakTicket {
pub host_addr: iroh::EndpointAddr,
@@ -83,6 +95,9 @@ pub trait NetworkTransport: Send + Sync {
/// Subscribes to incoming datagrams from any peer.
async fn receive_datagrams(&self) -> Result<Receiver<(EndpointId, Bytes)>, NetError>;
/// Subscribes to per-peer link-state changes (connecting / connected).
async fn subscribe_conn_events(&self) -> Result<Receiver<ConnEvent>, NetError>;
}
#[async_trait]