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:
+46
-13
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user