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
+1 -2
View File
@@ -30,8 +30,7 @@ async fn make_transport() -> Arc<IrohTransport> {
.bind()
.await
.expect("bind endpoint");
let (transport, _audio_proto) = IrohTransport::new(endpoint);
Arc::new(transport)
Arc::new(IrohTransport::new(endpoint))
}
/// A peer identity with no live endpoint — all we need to key the handler's maps.
+8 -4
View File
@@ -24,7 +24,7 @@ use peerspeak::codec::AudioEncoder;
use peerspeak::codec::opus_impl::OpusEncoder;
use peerspeak::core::jitter::{FRAME_SAMPLES, JitterBuffer};
use peerspeak::network::{ConnEvent, NetworkTransport};
use peerspeak::network::iroh_impl::IrohTransport;
use peerspeak::network::iroh_impl::{AudioRouter, IrohTransport};
const AUDIO_ALPN: &[u8] = b"peerspeak-audio";
@@ -110,14 +110,18 @@ async fn spawn_node_with_key(secret: iroh::SecretKey) -> Node {
.await
.expect("bind endpoint");
let (transport, audio_proto) = IrohTransport::new(endpoint.clone());
let transport = Arc::new(IrohTransport::new(endpoint.clone()));
// Mirror production: a persistent AudioRouter bound to this session's transport
// is what the router accepts inbound audio links on.
let audio_router = AudioRouter::new();
audio_router.bind(&transport);
let router = Router::builder(endpoint.clone())
.accept(AUDIO_ALPN, audio_proto)
.accept(AUDIO_ALPN, audio_router)
.spawn();
Node {
endpoint,
transport: Arc::new(transport),
transport,
_router: router,
lookup,
}