refactor(net): persistent endpoint/gossip/router (W7 B1)

The friends-only presence listener (W7) must answer pings while the app is
open, whether or not we're in a call — but a node id has exactly one live
endpoint instance (proven by the dual-endpoint spike: two endpoints sharing a
SecretKey collide, all inbound connections land on one and the other's ALPN
fails the QUIC handshake). So the listener can't get its own endpoint; the
whole app must share one persistent endpoint. Today the core rebuilds the
endpoint+gossip+router on every Join and tears them down on leave, so there's
nothing alive between calls.

B1 hoists those durable pieces to the app lifetime (no new behavior):

- New persistent `NetStack` (endpoint + gossip + Router) built once at startup
  under the RelayNoDiscovery default (relay reachability, no DNS beacon);
  `online()` is backgrounded so launch isn't blocked.
- New persistent `AudioRouter` (src/network/iroh_impl.rs) replaces the
  per-session `AudioProtocol`: it's registered once on the single Router and
  delegates each inbound audio connection to whatever session `Shared` is bound
  (`bind` on join, `clear` on leave), dropping links when idle. `IrohTransport::
  new` now returns just `Self`.
- Join reuses `net.endpoint`/`net.gossip` and only subscribes its gossip topic +
  binds the audio router; Leave clears the router but keeps the endpoint up.
- `SetNetworkMode`/`RegenerateIdentity` rebuild the stack immediately when idle,
  else defer to the next Leave/Join (preserves "applies on next join"), and the
  existing session is always torn down before any rebuild closes the endpoint.

Tests/loopback updated for the new transport API. 256 lib + 6 reconnect + 4
loopback + 2 ignored real-endpoint tests green, clippy --all-targets clean,
release builds. NOT yet 2-machine field-verified — that regression is the gate
before this merges to main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 15:44:19 -04:00
co-authored by Claude Opus 4.8
parent d50c05744f
commit fdab4e03a2
6 changed files with 285 additions and 107 deletions
+46 -13
View File
@@ -276,25 +276,57 @@ async fn obtain_conn(
}
}
#[derive(Clone)]
pub struct AudioProtocol {
shared: Arc<Shared>,
/// 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 AudioProtocol {
impl std::fmt::Debug for AudioRouter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AudioProtocol").finish_non_exhaustive()
f.debug_struct("AudioRouter").finish_non_exhaustive()
}
}
impl iroh::protocol::ProtocolHandler for AudioProtocol {
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();
let shared = self.shared.clone();
// 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.
@@ -321,7 +353,11 @@ pub struct IrohTransport {
}
impl IrohTransport {
pub fn new(endpoint: Endpoint) -> (Self, AudioProtocol) {
/// 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();
@@ -337,14 +373,11 @@ impl IrohTransport {
conn_events_tx,
});
let protocol = AudioProtocol { shared: shared.clone() };
let transport = Self {
Self {
shared,
incoming_rx: tokio::sync::Mutex::new(Some(incoming_rx)),
conn_events_rx: tokio::sync::Mutex::new(Some(conn_events_rx)),
};
(transport, protocol)
}
}
/// Tear down for an intentional leave/quit: close every live connection with