Files
peerspeak/src/network/iroh_impl.rs
T
molluskandClaude Fable 5 d2432740c1 network: per-peer connection badge (direct/relay, RTT, loss, bitrate)
Answer "am I actually P2P right now?" per peer. A 1 Hz session task
snapshots the selected QUIC path of every live audio connection
(IrohTransport::connection_stats), core::connstats::derive turns
consecutive snapshots into RTT/loss/bitrate (path switches and counter
resets invalidate the rate window), and the peer card shows a
Direct/Relay badge with a hover tooltip for address, loss, and up/down
bitrate. No new dependencies, no wire change.

Loopback-integration-tested against real iroh endpoints; not yet
field-verified on a 2-machine call (FEATURES.md row marked 🧪).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 15:15:22 -04:00

874 lines
36 KiB
Rust

use crate::network::{ConnEvent, NetError, NetworkTransport};
use async_trait::async_trait;
use bytes::Bytes;
use iroh::endpoint::{Connection, ConnectionError, VarInt};
use iroh::{Endpoint, EndpointId};
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex as StdMutex};
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::sync::mpsc::Receiver;
use crate::files::{AttachmentId, ChatAttachment};
use crate::protocol::{AUDIO_ALPN, FILES_ALPN};
/// Per-peer datagram send queue depth. Audio is real-time, so a backlog is
/// useless latency — keep it shallow and drop the oldest frame when full.
const SEND_QUEUE_DEPTH: usize = 8;
/// How many inbound connections we'll buffer for a single peer's supervisor.
/// During a healthy link the supervisor drains this promptly; a small buffer
/// just covers the brief window between detecting a drop and re-awaiting.
const INBOUND_QUEUE_DEPTH: usize = 4;
/// Reconnect backoff bounds for the dialing side. Starts gentle so a momentary
/// blip recovers almost instantly, and caps so a long outage doesn't hammer.
const INITIAL_BACKOFF: Duration = Duration::from_millis(250);
const MAX_BACKOFF: Duration = Duration::from_secs(5);
/// QUIC application error code sent when we leave/quit intentionally, so the
/// remote supervisor can tell a graceful departure from a transient drop. The
/// specific value is informational; any *application* close (vs a timeout/reset)
/// already means "the peer closed this on purpose."
const GOODBYE_CODE: u32 = 1;
/// Bound on each phase (connect, read) of a chat-attachment fetch, so a slow or
/// stalled sender can't hang the fetch indefinitely.
const FILE_FETCH_TIMEOUT: Duration = Duration::from_secs(30);
/// State shared between the transport, its protocol handler, and every per-peer
/// supervisor task. One supervisor owns a peer's whole connection lifecycle.
struct Shared {
/// Used by dialer-side supervisors to (re)establish connections.
endpoint: Endpoint,
self_id: EndpointId,
/// Sync-lockable send handles, so `broadcast` can fan out from the (non-async)
/// capture/encode thread without touching the Tokio runtime. A supervisor
/// inserts its handle when a link comes up and removes it when the link dies,
/// so `broadcast` only ever targets peers that are currently reachable.
senders: StdMutex<HashMap<EndpointId, mpsc::Sender<Bytes>>>,
/// Last-known full address (relay + direct addrs) per peer, retained for the
/// supervisor's lifetime and refreshed whenever the peer (re)announces. The
/// dialer dials this directly so a reconnect never depends on the address
/// lookup, which a transient gossip `Leave`/`NeighborDown` can purge — the
/// bug that left the dialer redial-looping with "no address" forever.
addrs: StdMutex<HashMap<EndpointId, iroh::EndpointAddr>>,
/// One supervisor task per known peer.
peers: tokio::sync::Mutex<HashMap<EndpointId, PeerHandle>>,
/// The currently-live connection per peer, tracked so an intentional leave
/// can explicitly close each one with [`GOODBYE_CODE`] (a prompt, reliable
/// "I'm leaving" signal the remote can distinguish from a network drop). A
/// supervisor inserts its connection when the link comes up and removes it
/// when the link dies.
live_conns: StdMutex<HashMap<EndpointId, Connection>>,
/// Core-owned audio admission snapshot for this room session. It mirrors the
/// verified gossip roster plus peers still inside reconnect grace; transport
/// connections alone never mutate this set.
admitted_audio: StdMutex<HashSet<EndpointId>>,
/// Chat file attachments we're serving to room members this session, keyed by
/// the random attachment id. Populated when we send a chat file; read by the
/// file protocol handler to answer a member's fetch. Cleared on leave. Each
/// blob is already byte-capped at send time.
served_files: StdMutex<HashMap<crate::files::AttachmentId, Arc<Vec<u8>>>>,
incoming_tx: mpsc::Sender<(EndpointId, Bytes)>,
/// Best-effort link-state notifications for the UI (connecting / connected).
conn_events_tx: mpsc::Sender<ConnEvent>,
}
struct PeerHandle {
/// The supervisor owning this peer's connect → run → reconnect loop.
supervisor: tokio::task::JoinHandle<()>,
/// Inbound connections accepted for this peer are routed here so the
/// acceptor-side supervisor can pick them up (initial and reconnect).
inbound_tx: mpsc::Sender<Connection>,
}
/// Aborts a spawned task when dropped. The supervisor holds its send/read loops
/// in these so that cancelling the supervisor (via `remove`'s `abort`) tears the
/// loops down too — otherwise they'd keep their `Connection` clones alive and
/// the link would never actually close.
struct AbortOnDrop(tokio::task::JoinHandle<()>);
impl Drop for AbortOnDrop {
fn drop(&mut self) {
self.0.abort();
}
}
impl Shared {
/// Ensure a supervisor task exists for `peer_id`, returning its inbound
/// connection sender. Idempotent: a second call for a known peer just hands
/// back the existing sender. Both the reactive `connect_peer` path and the
/// inbound `accept()` path funnel through here, so whichever fires first
/// spins the peer up and the roles stay deterministic by id.
async fn ensure_supervisor(self: &Arc<Self>, peer_id: EndpointId) -> mpsc::Sender<Connection> {
let mut peers = self.peers.lock().await;
if let Some(handle) = peers.get(&peer_id) {
return handle.inbound_tx.clone();
}
let (inbound_tx, inbound_rx) = mpsc::channel::<Connection>(INBOUND_QUEUE_DEPTH);
let shared = self.clone();
let supervisor = tokio::spawn(supervise(shared, peer_id, inbound_rx));
let inbound_tx_ret = inbound_tx.clone();
peers.insert(
peer_id,
PeerHandle {
supervisor,
inbound_tx,
},
);
crate::log_msg(&format!("Transport: supervising peer {:?}", peer_id));
inbound_tx_ret
}
/// Permanently retire a peer (it left the room). Stops its supervisor — which
/// cancels any in-flight dial or live link — and drops its send handle.
async fn remove(&self, peer_id: EndpointId) {
self.senders.lock().unwrap().remove(&peer_id);
self.addrs.lock().unwrap().remove(&peer_id);
if let Some(handle) = self.peers.lock().await.remove(&peer_id) {
handle.supervisor.abort();
crate::log_msg(&format!(
"Transport: stopped supervising peer {:?}",
peer_id
));
}
}
fn audio_sender_admitted(&self, peer_id: EndpointId) -> bool {
let roster = self.admitted_audio.lock().unwrap();
audio_sender_admitted(peer_id, &roster)
}
fn apply_audio_admission(&self, peer_id: EndpointId, event: AudioAdmissionEvent) {
let mut roster = self.admitted_audio.lock().unwrap();
apply_audio_admission_event(&mut roster, peer_id, event);
}
}
/// Why a peer's live-link wait woke up.
enum Wake {
/// The QUIC connection closed; the error says how. An *application* close
/// means the peer left on purpose (reconnect would be wrong); a timeout/reset
/// is a transient drop we should reconnect through.
Closed(ConnectionError),
/// A fresh inbound connection arrived (acceptor side) — switch to it.
Replacement(Connection),
/// The peer was retired; tear down.
Shutdown,
}
/// True when the peer closed the connection with our [`GOODBYE_CODE`] — i.e. an
/// intentional leave/quit via [`IrohTransport::leave`]. Any other close (idle
/// timeout, reset, or an application close with a different code) is treated as a
/// transient drop to reconnect through, so this never mistakes a blip for a
/// departure.
fn is_graceful_leave(err: &ConnectionError) -> bool {
matches!(err, ConnectionError::ApplicationClosed(frame) if frame.error_code == VarInt::from_u32(GOODBYE_CODE))
}
/// Pure S8 membership decision: iroh already authenticated `remote` as the
/// connection's endpoint id, so audio admission is exactly live roster membership.
pub(crate) fn audio_sender_admitted(remote: EndpointId, roster: &HashSet<EndpointId>) -> bool {
roster.contains(&remote)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AudioAdmissionEvent {
/// A signed gossip Announce/Update says the peer is in the live room roster.
RosterPresent,
/// Gossip reported a transient drop; keep admission during reconnect grace.
TransientDropGrace,
/// Graceful leave, transport Left eviction, or reconnect-grace expiry.
Remove,
}
pub(crate) fn apply_audio_admission_event(
roster: &mut HashSet<EndpointId>,
peer_id: EndpointId,
event: AudioAdmissionEvent,
) {
match event {
AudioAdmissionEvent::RosterPresent => {
roster.insert(peer_id);
}
AudioAdmissionEvent::TransientDropGrace => {
// Grace is not an authority to add membership; it only preserves an
// already-admitted peer until either rejoin or grace expiry.
}
AudioAdmissionEvent::Remove => {
roster.remove(&peer_id);
}
}
}
/// Owns a single peer's connection lifecycle for as long as the peer is in the
/// room: obtain a link, run the send/read loops, and on loss obtain a new one —
/// with capped backoff on the dialing side. The deterministic-initiator rule
/// (lower id dials, higher id accepts) is the same on first connect and every
/// reconnect, so exactly one shared connection forms each time.
async fn supervise(
shared: Arc<Shared>,
peer_id: EndpointId,
mut inbound_rx: mpsc::Receiver<Connection>,
) {
let is_dialer = shared.self_id.to_string() < peer_id.to_string();
let mut backoff = INITIAL_BACKOFF;
// Show "connecting" until the first link is actually up.
let _ = shared
.conn_events_tx
.try_send(ConnEvent::Connecting(peer_id));
let mut conn =
match obtain_conn(&shared, peer_id, is_dialer, &mut inbound_rx, &mut backoff).await {
Some(conn) => conn,
None => return, // retired before we ever connected
};
loop {
// A healthy link resets the dialer's backoff for the next outage.
backoff = INITIAL_BACKOFF;
let (send_tx, mut send_rx) = mpsc::channel::<Bytes>(SEND_QUEUE_DEPTH);
shared.senders.lock().unwrap().insert(peer_id, send_tx);
// Publish the live connection so an intentional leave can close it with
// the goodbye code.
shared
.live_conns
.lock()
.unwrap()
.insert(peer_id, conn.clone());
let _ = shared
.conn_events_tx
.try_send(ConnEvent::Connected(peer_id));
crate::log_msg(&format!("Transport: peer {:?} link up", peer_id));
// Run until the link dies, a replacement arrives, or we're retired. The
// send/read loops are held in abort-on-drop guards scoped to this block,
// so however we leave it — normal exit or the supervisor being aborted
// mid-`select` — both loops stop and release their `Connection` clones.
let wake = {
let conn_send = conn.clone();
let _send_guard = AbortOnDrop(tokio::spawn(async move {
while let Some(data) = send_rx.recv().await {
if conn_send.send_datagram(data).is_err() {
break;
}
}
}));
let conn_read = conn.clone();
let incoming_tx = shared.incoming_tx.clone();
let _read_guard = AbortOnDrop(tokio::spawn(async move {
while let Ok(bytes) = conn_read.read_datagram().await {
if incoming_tx.send((peer_id, bytes)).await.is_err() {
break;
}
}
}));
// Only the acceptor watches for replacements: if the dialer notices
// the drop and re-dials before our own `closed()` fires, we switch to
// the new link immediately instead of waiting out the idle timeout.
tokio::select! {
reason = conn.closed() => Wake::Closed(reason),
inbound = inbound_rx.recv(), if !is_dialer => match inbound {
Some(new_conn) => Wake::Replacement(new_conn),
None => Wake::Shutdown,
},
}
};
// Dropping the send handle makes `broadcast` skip this peer until the
// link is back, instead of piling frames into a dead connection. The
// live connection is gone too until a new one is established.
shared.senders.lock().unwrap().remove(&peer_id);
shared.live_conns.lock().unwrap().remove(&peer_id);
match wake {
Wake::Shutdown => return,
Wake::Replacement(new_conn) => {
crate::log_msg(&format!(
"Transport: peer {:?} replaced with new inbound link",
peer_id
));
let _ = shared
.conn_events_tx
.try_send(ConnEvent::Connecting(peer_id));
conn = new_conn;
}
Wake::Closed(reason) => {
// A graceful application close means the peer left on purpose —
// don't reconnect; tell the core to evict it now.
if is_graceful_leave(&reason) {
crate::log_msg(&format!(
"Transport: peer {:?} left gracefully ({:?})",
peer_id, reason
));
let _ = shared.conn_events_tx.try_send(ConnEvent::Left(peer_id));
return;
}
crate::log_msg(&format!(
"Transport: peer {:?} link dropped; reconnecting",
peer_id
));
let _ = shared
.conn_events_tx
.try_send(ConnEvent::Connecting(peer_id));
conn = match obtain_conn(&shared, peer_id, is_dialer, &mut inbound_rx, &mut backoff)
.await
{
Some(conn) => conn,
None => return, // retired while reconnecting
};
}
}
}
}
/// Block until we hold a live connection to the peer, or `None` if the peer was
/// retired first. The dialer redials with capped exponential backoff; the
/// acceptor waits for the dialer to come to it (a closed `inbound_rx` means the
/// supervisor was retired).
async fn obtain_conn(
shared: &Arc<Shared>,
peer_id: EndpointId,
is_dialer: bool,
inbound_rx: &mut mpsc::Receiver<Connection>,
backoff: &mut Duration,
) -> Option<Connection> {
if is_dialer {
loop {
// Dial the retained full address (relay + direct addrs) so reconnects
// don't depend on the address lookup; it's re-read each attempt so a
// refreshed address (peer back on a new one) is picked up next retry.
// Fall back to the bare id (lookup/relay resolution) only if we somehow
// never recorded an address — the dialer path always carries one.
let target = shared.addrs.lock().unwrap().get(&peer_id).cloned();
let result = match target {
Some(addr) => shared.endpoint.connect(addr, AUDIO_ALPN).await,
None => shared.endpoint.connect(peer_id, AUDIO_ALPN).await,
};
match result {
Ok(conn) => return Some(conn),
Err(e) => {
crate::log_msg(&format!(
"Transport: dial to {:?} failed: {:?}; retrying in {:?}",
peer_id, e, backoff
));
tokio::time::sleep(*backoff).await;
*backoff = (*backoff * 2).min(MAX_BACKOFF);
}
}
}
} else {
inbound_rx.recv().await
}
}
/// The persistent audio protocol handler. Registered ONCE on the app's single
/// long-lived [`Router`](iroh::protocol::Router), it delegates each inbound audio
/// connection to whatever session's [`Shared`] is currently bound, or drops it
/// when idle (no active call). This is what lets the endpoint + router outlive any
/// individual room session — the W7 friends listener needs the endpoint reachable
/// *between* calls — while the per-peer connection state stays per-session.
#[derive(Clone, Default)]
pub struct AudioRouter {
/// The active session's transport state, swapped in on join and cleared on
/// leave. `None` means "no call" → inbound audio links are dropped.
current: Arc<StdMutex<Option<Arc<Shared>>>>,
}
impl std::fmt::Debug for AudioRouter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AudioRouter").finish_non_exhaustive()
}
}
impl AudioRouter {
pub fn new() -> Self {
Self::default()
}
/// Route inbound audio connections to `transport`'s session (called on join).
pub fn bind(&self, transport: &IrohTransport) {
*self.current.lock().unwrap() = Some(transport.shared.clone());
}
/// Stop routing — drop inbound audio links until the next [`bind`](Self::bind)
/// (called on leave). The endpoint/router stay alive; we just have nowhere to
/// hand a fresh audio connection.
pub fn clear(&self) {
*self.current.lock().unwrap() = None;
}
}
impl iroh::protocol::ProtocolHandler for AudioRouter {
fn accept(
&self,
connection: Connection,
) -> impl std::future::Future<Output = Result<(), iroh::protocol::AcceptError>> + Send {
let peer_id = connection.remote_id();
// Snapshot the current session (clone the Arc) and release the lock before
// any await — the guard isn't held across suspension points.
let shared = self.current.lock().unwrap().clone();
async move {
// No active call → nothing to route this inbound link to; drop it.
let Some(shared) = shared else {
return Ok(());
};
// Deterministic roles: the lower id dials, the higher id accepts.
// If we're the dialer for this peer we never consume inbound links
// (we make our own), so just let this one drop.
if shared.self_id.to_string() < peer_id.to_string() {
return Ok(());
}
if !shared.audio_sender_admitted(peer_id) {
crate::log_msg(&format!(
"Transport: rejected inbound audio from non-member {}",
crate::short_id(&peer_id.to_string())
));
return Ok(());
}
// Route the connection to this peer's supervisor (creating it if the
// inbound link arrives after the signed gossip Announce admitted it).
// try_send keeps the protocol handler from ever blocking; a full queue
// only happens if links are churning, and the supervisor gets the next one.
let inbound_tx = shared.ensure_supervisor(peer_id).await;
if inbound_tx.try_send(connection).is_err() {
crate::log_msg(&format!(
"Transport: dropped inbound link from {:?} (queue full)",
peer_id
));
}
Ok(())
}
}
}
/// Protocol handler for the file-transfer plane (`FILES_ALPN`). Mirrors
/// [`AudioRouter`]: it's persistent on the router and bound to the active
/// session's [`Shared`] on join. On an inbound stream it authenticates the peer
/// (iroh ALPN handshake gives us `remote_id`), gates on **live room membership**
/// (same invariant as audio admission, so a former member can't pull files),
/// reads a single 32-byte attachment id, and streams back the matching blob from
/// the session serve store — or nothing if the id is unknown.
#[derive(Clone, Default)]
pub struct FileRouter {
current: Arc<StdMutex<Option<Arc<Shared>>>>,
}
impl std::fmt::Debug for FileRouter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FileRouter").finish_non_exhaustive()
}
}
impl FileRouter {
pub fn new() -> Self {
Self::default()
}
/// Route inbound file fetches to `transport`'s session (called on join).
pub fn bind(&self, transport: &IrohTransport) {
*self.current.lock().unwrap() = Some(transport.shared.clone());
}
/// Stop serving files until the next [`bind`](Self::bind) (called on leave).
pub fn clear(&self) {
*self.current.lock().unwrap() = None;
}
}
/// Max bytes we'll read for a fetch *request* frame. A request is exactly one
/// 32-byte id; this small ceiling rejects a peer trying to stream us a huge
/// "request" as a cheap DoS.
const FILE_REQUEST_MAX: usize = 64;
impl iroh::protocol::ProtocolHandler for FileRouter {
fn accept(
&self,
connection: Connection,
) -> impl std::future::Future<Output = Result<(), iroh::protocol::AcceptError>> + Send {
let peer_id = connection.remote_id();
let shared = self.current.lock().unwrap().clone();
async move {
// No active call → nothing to serve.
let Some(shared) = shared else {
return Ok(());
};
// Member gating: only current room members may fetch our files. Reuses
// the audio admission roster (the authoritative room membership set).
if !shared.audio_sender_admitted(peer_id) {
crate::log_msg(&format!(
"Transport: rejected file fetch from non-member {}",
crate::short_id(&peer_id.to_string())
));
return Ok(());
}
// Accept one bidirectional stream: read the id, write the bytes.
let Ok((mut send, mut recv)) = connection.accept_bi().await else {
return Ok(());
};
let Ok(req) = recv.read_to_end(FILE_REQUEST_MAX).await else {
return Ok(());
};
let Some(id) = crate::files::parse_request(&req) else {
return Ok(());
};
let blob = shared.served_files.lock().unwrap().get(&id).cloned();
if let Some(blob) = blob {
let _ = send.write_all(&blob).await;
}
// Finish either way: an unknown id closes with an empty body, which
// the fetcher reads as a zero-length result and treats as "gone".
let _ = send.finish();
// CRITICAL: `finish()` only marks the stream's EOF — it does NOT wait
// for the written bytes to be delivered and acknowledged. If we return
// here the `connection` drops, and its CONNECTION_CLOSE can race ahead
// of the still-in-flight stream data, so the fetcher's read aborts with
// "connection lost". Wait for the fetcher to receive everything and
// close the connection itself (it drops `conn` right after read_to_end);
// that close is our signal the transfer landed. Bounded so a fetcher
// that vanishes can't pin this task forever.
let _ = tokio::time::timeout(FILE_FETCH_TIMEOUT, connection.closed()).await;
Ok(())
}
}
}
pub struct IrohTransport {
shared: Arc<Shared>,
incoming_rx: tokio::sync::Mutex<Option<mpsc::Receiver<(EndpointId, Bytes)>>>,
conn_events_rx: tokio::sync::Mutex<Option<mpsc::Receiver<ConnEvent>>>,
}
impl IrohTransport {
/// Build a per-session transport over the app's persistent `endpoint`. The
/// inbound audio handler is the persistent [`AudioRouter`] (registered once on
/// the router) — call [`AudioRouter::bind`] with this transport to route this
/// session's inbound links to it.
pub fn new(endpoint: Endpoint) -> Self {
let (incoming_tx, incoming_rx) = mpsc::channel(1000);
let (conn_events_tx, conn_events_rx) = mpsc::channel(100);
let self_id = endpoint.id();
let shared = Arc::new(Shared {
endpoint,
self_id,
senders: StdMutex::new(HashMap::new()),
addrs: StdMutex::new(HashMap::new()),
peers: tokio::sync::Mutex::new(HashMap::new()),
live_conns: StdMutex::new(HashMap::new()),
admitted_audio: StdMutex::new(HashSet::new()),
served_files: StdMutex::new(HashMap::new()),
incoming_tx,
conn_events_tx,
});
Self {
shared,
incoming_rx: tokio::sync::Mutex::new(Some(incoming_rx)),
conn_events_rx: tokio::sync::Mutex::new(Some(conn_events_rx)),
}
}
/// Tear down for an intentional leave/quit: close every live connection with
/// the graceful goodbye code so each remote peer evicts us promptly (its
/// supervisor sees an application close, not a reconnectable drop), then stop
/// all supervisors so none linger redialing the about-to-close endpoint.
/// Call this before shutting the router down.
pub async fn leave(&self) {
let conns: Vec<Connection> = self
.shared
.live_conns
.lock()
.unwrap()
.drain()
.map(|(_, c)| c)
.collect();
for conn in &conns {
conn.close(VarInt::from_u32(GOODBYE_CODE), b"leave");
}
// Stop every supervisor so it can't re-dial during/after teardown.
let mut peers = self.shared.peers.lock().await;
for (_, handle) in peers.drain() {
handle.supervisor.abort();
}
self.shared.senders.lock().unwrap().clear();
self.shared.addrs.lock().unwrap().clear();
self.shared.admitted_audio.lock().unwrap().clear();
self.shared.served_files.lock().unwrap().clear();
// Give the CONNECTION_CLOSE frames a moment to flush before the caller
// shuts the endpoint/router down (the `conns` clones are still alive
// here, so the endpoint can still transmit them).
tokio::time::sleep(Duration::from_millis(150)).await;
}
/// Admit a peer to this session's audio plane. Core calls this from verified
/// gossip roster events; the transport never derives membership on its own.
pub fn admit_audio_sender(&self, peer_id: EndpointId) {
self.shared
.apply_audio_admission(peer_id, AudioAdmissionEvent::RosterPresent);
}
/// Preserve an already-admitted peer through the reconnect grace window.
pub fn keep_audio_sender_for_reconnect_grace(&self, peer_id: EndpointId) {
self.shared
.apply_audio_admission(peer_id, AudioAdmissionEvent::TransientDropGrace);
}
/// Remove a peer from audio admission before tearing down transport/jitter state.
pub fn remove_audio_sender(&self, peer_id: EndpointId) {
self.shared
.apply_audio_admission(peer_id, AudioAdmissionEvent::Remove);
}
pub fn audio_sender_admitted(&self, peer_id: EndpointId) -> bool {
self.shared.audio_sender_admitted(peer_id)
}
/// Make `bytes` available to room members under `id` for the rest of this
/// session (served by the [`FileRouter`] handler). Called by core when we
/// send a chat file. The blob is cleared on leave.
pub fn serve_attachment(&self, id: AttachmentId, bytes: Arc<Vec<u8>>) {
self.shared.served_files.lock().unwrap().insert(id, bytes);
}
/// Drop a previously-served blob (e.g. a music track no longer current-or-next).
pub fn forget_attachment(&self, id: AttachmentId) {
self.shared.served_files.lock().unwrap().remove(&id);
}
/// Fetch `size` bytes stored under `id` from peer `from` over the files plane.
/// Shared core of `fetch_attachment` and music-track fetching: dials
/// `FILES_ALPN`, writes the 32-byte id, and reads bounded by `size`.
pub async fn fetch_blob(
&self,
from: EndpointId,
id: AttachmentId,
size: u64,
) -> Result<Vec<u8>, NetError> {
if !crate::files::size_within_cap(size) {
return Err(NetError::Other("attachment size out of range".to_string()));
}
let addr = self.shared.addrs.lock().unwrap().get(&from).cloned();
let connect = async {
match addr {
Some(addr) => self.shared.endpoint.connect(addr, FILES_ALPN).await,
None => self.shared.endpoint.connect(from, FILES_ALPN).await,
}
};
let conn = tokio::time::timeout(FILE_FETCH_TIMEOUT, connect)
.await
.map_err(|_| NetError::Other("file fetch: connect timed out".to_string()))?
.map_err(|e| NetError::Other(format!("file fetch: connect failed: {e}")))?;
let (mut send, mut recv) = conn
.open_bi()
.await
.map_err(|e| NetError::Other(format!("file fetch: open stream failed: {e}")))?;
send.write_all(&id)
.await
.map_err(|e| NetError::Other(format!("file fetch: request write failed: {e}")))?;
send.finish()
.map_err(|e| NetError::Other(format!("file fetch: request finish failed: {e}")))?;
let read = recv.read_to_end(size as usize);
let bytes = tokio::time::timeout(FILE_FETCH_TIMEOUT, read)
.await
.map_err(|_| NetError::Other("file fetch: read timed out".to_string()))?
.map_err(|e| NetError::Other(format!("file fetch: read failed: {e}")))?;
if bytes.is_empty() {
return Err(NetError::Other(
"file fetch: sender no longer has the file".to_string(),
));
}
Ok(bytes)
}
/// Snapshot the selected QUIC path of every live audio connection, for the
/// UI's per-peer connection badge (direct/relay, RTT, loss, bitrate).
/// Cheap and lock-light: the `live_conns` guard is released before touching
/// any connection, and `Connection::paths()` reads shared state without I/O.
pub fn connection_stats(&self) -> Vec<(EndpointId, crate::network::PathSnapshot)> {
// Clone the connections out so the map lock isn't held while we inspect
// paths (a supervisor inserts/removes entries as links come and go).
let conns: Vec<(EndpointId, Connection)> = self
.shared
.live_conns
.lock()
.unwrap()
.iter()
.map(|(id, conn)| (*id, conn.clone()))
.collect();
conns
.into_iter()
.filter_map(|(id, conn)| {
let paths = conn.paths();
// The selected path is the one carrying application data. In the
// brief window where none is flagged (e.g. mid-migration), fall
// back to the first open path rather than dropping the badge.
let path = paths
.iter()
.find(|p| p.is_selected())
.or_else(|| paths.iter().next())?;
let stats = path.stats();
// Per-variant display: `TransportAddr`'s own `Display` prefixes
// a scheme ("ip:1.2.3.4:5") that's noise next to the badge's
// Direct/Relay label.
let remote_addr = match path.remote_addr() {
iroh::TransportAddr::Ip(sock) => sock.to_string(),
iroh::TransportAddr::Relay(url) => url.to_string(),
other => other.to_string(),
};
Some((
id,
crate::network::PathSnapshot {
is_relay: path.remote_addr().is_relay(),
remote_addr,
rtt: stats.rtt,
tx_bytes: stats.udp_tx.bytes,
rx_bytes: stats.udp_rx.bytes,
tx_datagrams: stats.udp_tx.datagrams,
lost_packets: stats.lost_packets,
},
))
})
.collect()
}
/// Fetch a chat attachment's bytes from its sender over the file plane.
pub async fn fetch_attachment(
&self,
from: EndpointId,
att: &ChatAttachment,
) -> Result<Vec<u8>, NetError> {
self.fetch_blob(from, att.id, att.size).await
}
}
#[async_trait]
impl NetworkTransport for IrohTransport {
async fn connect_peer(&self, addr: iroh::EndpointAddr) {
let peer_id = addr.id;
// Retain (or refresh) the peer's full address so the dialer can re-reach
// it across reconnects without leaning on the address lookup.
self.shared.addrs.lock().unwrap().insert(peer_id, addr);
// Start (or confirm) the peer's supervisor. It owns dialing/accepting and
// reconnection from here on; this call is just the reactive trigger.
self.shared.ensure_supervisor(peer_id).await;
}
async fn disconnect_peer(&self, peer_id: EndpointId) {
self.shared.remove(peer_id).await;
}
fn broadcast(&self, data: Bytes) {
let senders = self.shared.senders.lock().unwrap();
for tx in senders.values() {
// Drop on a full queue: stale audio is worthless, and we must never
// block the encode thread on a slow peer.
let _ = tx.try_send(data.clone());
}
}
async fn receive_datagrams(&self) -> Result<Receiver<(EndpointId, Bytes)>, NetError> {
let mut rx_guard = self.incoming_rx.lock().await;
rx_guard
.take()
.ok_or_else(|| NetError::Other("Datagram receiver already subscribed".to_string()))
}
async fn subscribe_conn_events(&self) -> Result<Receiver<ConnEvent>, NetError> {
let mut rx_guard = self.conn_events_rx.lock().await;
rx_guard
.take()
.ok_or_else(|| NetError::Other("Connection events already subscribed".to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use iroh::SecretKey;
fn endpoint_id() -> EndpointId {
SecretKey::generate().public()
}
#[test]
fn audio_sender_admission_accepts_roster_member() {
let member = endpoint_id();
let roster = HashSet::from([member]);
assert!(audio_sender_admitted(member, &roster));
}
#[test]
fn audio_sender_admission_rejects_unknown_sender() {
let member = endpoint_id();
let stranger = endpoint_id();
let roster = HashSet::from([member]);
assert!(!audio_sender_admitted(stranger, &roster));
}
#[test]
fn audio_sender_admission_rejects_former_member_after_roster_removal() {
let former = endpoint_id();
let mut roster = HashSet::from([former]);
assert!(audio_sender_admitted(former, &roster));
roster.remove(&former);
assert!(!audio_sender_admitted(former, &roster));
}
#[test]
fn audio_sender_admission_waits_for_mid_join_announce() {
let joining_peer = endpoint_id();
let mut roster = HashSet::new();
assert!(!audio_sender_admitted(joining_peer, &roster));
roster.insert(joining_peer);
assert!(audio_sender_admitted(joining_peer, &roster));
}
#[test]
fn audio_admission_lifecycle_keeps_peer_through_transient_grace() {
let peer = endpoint_id();
let mut roster = HashSet::new();
apply_audio_admission_event(&mut roster, peer, AudioAdmissionEvent::RosterPresent);
assert!(audio_sender_admitted(peer, &roster));
apply_audio_admission_event(&mut roster, peer, AudioAdmissionEvent::TransientDropGrace);
assert!(audio_sender_admitted(peer, &roster));
}
#[test]
fn audio_admission_lifecycle_does_not_add_unknown_peer_on_grace_event() {
let peer = endpoint_id();
let mut roster = HashSet::new();
apply_audio_admission_event(&mut roster, peer, AudioAdmissionEvent::TransientDropGrace);
assert!(!audio_sender_admitted(peer, &roster));
}
#[test]
fn audio_admission_lifecycle_removes_peer_on_leave_or_grace_expiry() {
let peer = endpoint_id();
let mut roster = HashSet::from([peer]);
apply_audio_admission_event(&mut roster, peer, AudioAdmissionEvent::Remove);
assert!(!audio_sender_admitted(peer, &roster));
}
}