//! End-to-end loopback test for the redesigned audio transport. //! //! Spins up two real iroh endpoints on localhost (relay disabled, addresses //! exchanged directly) and drives the actual production path: reactive //! `connect_peer`, sequenced `broadcast`, `receive_datagrams`, and the //! per-peer `JitterBuffer` decode. No microphone, speakers, or GUI required. use std::sync::Arc; use std::time::Duration; use tokio::sync::mpsc; use bytes::Bytes; use iroh::address_lookup::memory::MemoryLookup; use iroh::endpoint::presets; use iroh::protocol::Router; use iroh::{Endpoint, RelayMode}; use opus::{Application, Channels}; use iroh::endpoint::Connection; use iroh::protocol::{AcceptError, ProtocolHandler}; use peerspeak::codec::AudioEncoder; use peerspeak::codec::opus_impl::OpusEncoder; use peerspeak::core::jitter::{FRAME_SAMPLES, JitterBuffer}; use peerspeak::network::iroh_impl::{AudioRouter, IrohTransport}; use peerspeak::network::{ConnEvent, NetworkTransport}; use peerspeak::protocol::AUDIO_ALPN; struct Node { endpoint: Endpoint, transport: Arc, _router: Router, lookup: MemoryLookup, } async fn spawn_node() -> Node { spawn_node_with_key(iroh::SecretKey::generate()).await } /// A minimal protocol handler for the reconnect test's "peer" side: it hands /// every accepted audio connection out through a channel so the test can hold /// it, read datagrams from it, and explicitly `close()` it to drive a drop — /// all on a single, stable endpoint address that the dialer can re-reach. #[derive(Clone)] struct CaptureProtocol { conns_tx: mpsc::UnboundedSender, } impl std::fmt::Debug for CaptureProtocol { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("CaptureProtocol").finish_non_exhaustive() } } impl ProtocolHandler for CaptureProtocol { fn accept( &self, connection: Connection, ) -> impl std::future::Future> + Send { let conns_tx = self.conns_tx.clone(); async move { // The channel retains the connection, which keeps it open after this // future resolves (iroh closes accepted connections with no retained // handle). let _ = conns_tx.send(connection); Ok(()) } } } struct CapturePeer { endpoint: Endpoint, _router: Router, lookup: MemoryLookup, conns_rx: mpsc::UnboundedReceiver, } async fn spawn_capture_peer(secret: iroh::SecretKey) -> CapturePeer { let lookup = MemoryLookup::new(); let endpoint = Endpoint::builder(presets::Minimal) .secret_key(secret) .relay_mode(RelayMode::Disabled) .address_lookup(lookup.clone()) .bind() .await .expect("bind capture peer"); let (conns_tx, conns_rx) = mpsc::unbounded_channel(); let router = Router::builder(endpoint.clone()) .accept(AUDIO_ALPN, CaptureProtocol { conns_tx }) .spawn(); CapturePeer { endpoint, _router: router, lookup, conns_rx, } } /// Spawn a node with a specific secret key. Reusing a key gives the respawned /// node the same `EndpointId`, which is how we simulate a peer dropping off the /// network and coming back (new address, same identity) for the reconnect test. async fn spawn_node_with_key(secret: iroh::SecretKey) -> Node { let lookup = MemoryLookup::new(); let endpoint = Endpoint::builder(presets::Minimal) .secret_key(secret) // Direct-only: two endpoints on the same host reach each other via the // addresses we seed into each other's lookup, with no external relay. .relay_mode(RelayMode::Disabled) .address_lookup(lookup.clone()) .bind() .await .expect("bind endpoint"); 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_router) .spawn(); Node { endpoint, transport, _router: router, lookup, } } /// One real, decodable Opus packet for a 20ms mono frame, prefixed with the /// 4-byte little-endian sequence header the transport/jitter buffer expect. fn packet(enc: &mut OpusEncoder, seq: u32) -> Bytes { let pcm: Vec = (0..FRAME_SAMPLES) .map(|i| if i % 2 == 0 { 2000 } else { -2000 }) .collect(); let encoded = enc.encode(&pcm).unwrap(); let mut buf = Vec::with_capacity(4 + encoded.len()); buf.extend_from_slice(&seq.to_le_bytes()); buf.extend_from_slice(&encoded); Bytes::from(buf) } /// Drain link-state events until a `Connected` arrives, returning whether a /// `Connecting` was seen first (the down→up transition). Panics on timeout so a /// stuck reconnect fails loudly rather than hanging the suite. async fn await_reconnect( rx: &mut mpsc::Receiver, deadline: tokio::time::Instant, ) -> bool { let mut saw_connecting = false; loop { match tokio::time::timeout_at(deadline, rx.recv()).await { Ok(Some(ConnEvent::Connecting(_))) => saw_connecting = true, Ok(Some(ConnEvent::Connected(_))) => return saw_connecting, Ok(Some(ConnEvent::Left(_))) => panic!("unexpected graceful Left during reconnect"), Ok(None) => panic!("conn events channel closed unexpectedly"), Err(_) => panic!("timed out waiting for Connected"), } } } #[tokio::test] async fn loopback_sequenced_audio_reaches_peer_and_decodes() { let a = spawn_node().await; let b = spawn_node().await; // Seed each side with the other's full address so direct dialing works. a.lookup.add_endpoint_info(b.endpoint.addr()); b.lookup.add_endpoint_info(a.endpoint.addr()); let a_id = a.endpoint.id(); let b_id = b.endpoint.id(); a.transport.admit_audio_sender(b_id); b.transport.admit_audio_sender(a_id); // Subscribe to incoming datagrams on B before any are sent. let mut b_rx = b.transport.receive_datagrams().await.expect("subscribe B"); // Reactive connection setup, exactly as core does on peer-join. Calling on // both sides is fine: the lower EndpointId dials, the higher accepts, and a // single shared connection forms. a.transport.connect_peer(b.endpoint.addr()).await; b.transport.connect_peer(a.endpoint.addr()).await; // Let the dial + accept registration settle. tokio::time::sleep(Duration::from_millis(500)).await; // A sends 50 sequenced frames. const N: u32 = 50; let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap(); for seq in 0..N { a.transport.broadcast(packet(&mut enc, seq)); tokio::time::sleep(Duration::from_millis(5)).await; } // Collect what B receives and feed it through a real jitter buffer, // popping a frame per arrival to mirror the mixer's steady 20ms cadence // (so the buffer stays shallow rather than overflowing its cap). let mut jitter = JitterBuffer::new().unwrap(); let mut received = 0u32; let mut decoded_frames = 0u32; let deadline = tokio::time::Instant::now() + Duration::from_secs(2); // Loop ends when the channel closes or the deadline is hit (pattern stops matching). while let Ok(Some((from, bytes))) = tokio::time::timeout_at(deadline, b_rx.recv()).await { assert_eq!(from, a_id, "datagram should be attributed to sender A"); assert!(bytes.len() >= 4, "packet carries a sequence header"); let seq = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]); jitter.insert(seq, bytes[4..].to_vec()); received += 1; if let Some(frame) = jitter.pop_frame() { assert_eq!( frame.len(), FRAME_SAMPLES, "decoded frame is one 20ms frame" ); decoded_frames += 1; } if received >= N { break; } } // Drain whatever remains buffered behind the playout delay. while let Some(frame) = jitter.pop_frame() { assert_eq!(frame.len(), FRAME_SAMPLES); decoded_frames += 1; } // On localhost essentially nothing should be lost over a real QUIC datagram path. assert!( received >= N - 2, "expected to receive ~{N} datagrams, got {received}" ); // And nearly all received packets should decode to PCM (a few absorbed by // the initial priming delay). assert!( decoded_frames >= N - 5, "expected to decode ~{N} frames, got {decoded_frames} (received {received})" ); } /// Read datagrams off a raw connection until `target` arrive or the deadline /// passes, asserting each carries the 4-byte sequence header. async fn count_audio(conn: &Connection, target: u32, deadline: tokio::time::Instant) -> u32 { let mut received = 0; while let Ok(Ok(bytes)) = tokio::time::timeout_at(deadline, conn.read_datagram()).await { assert!(bytes.len() >= 4, "packet carries a sequence header"); received += 1; if received >= target { break; } } received } #[tokio::test] async fn dialer_reconnects_after_link_drops() { // The unit under test is the IrohTransport dialer. The peer is a hand-rolled // endpoint we fully control, so we can explicitly close the live connection // (which the dialer observes promptly, unlike a silent handle drop) and then // accept the dialer's re-dial — all on one stable address it can re-reach. // // The dialer must hold the lower id so the transport's deterministic rule // makes it dial, so order the two identities up front. let s1 = iroh::SecretKey::generate(); let s2 = iroh::SecretKey::generate(); let (dialer_secret, peer_secret) = if s1.public().to_string() < s2.public().to_string() { (s1, s2) } else { (s2, s1) }; let dialer = spawn_node_with_key(dialer_secret).await; let mut peer = spawn_capture_peer(peer_secret).await; let dialer_id = dialer.endpoint.id(); let peer_id = peer.endpoint.id(); assert!(dialer_id.to_string() < peer_id.to_string()); // Seed addresses both ways and form the initial link. dialer.lookup.add_endpoint_info(peer.endpoint.addr()); peer.lookup.add_endpoint_info(dialer.endpoint.addr()); let mut conn_events = dialer .transport .subscribe_conn_events() .await .expect("subscribe dialer conn events"); dialer.transport.connect_peer(peer.endpoint.addr()).await; // The peer accepts the dialer's initial connection. let conn1 = tokio::time::timeout(Duration::from_secs(10), peer.conns_rx.recv()) .await .expect("timed out awaiting initial connection") .expect("connection channel closed"); assert!( await_reconnect( &mut conn_events, tokio::time::Instant::now() + Duration::from_secs(10) ) .await, "initial link should report Connecting then Connected" ); // Drop the live link by closing it from the peer side. An explicit close // sends a CONNECTION_CLOSE the dialer sees right away. conn1.close(0u32.into(), b"transient drop"); drop(conn1); // The dialer's supervisor should re-dial the (unchanged) peer address, and // the peer accepts the fresh connection. let conn2 = tokio::time::timeout(Duration::from_secs(15), peer.conns_rx.recv()) .await .expect("timed out awaiting reconnect") .expect("connection channel closed"); assert!( await_reconnect( &mut conn_events, tokio::time::Instant::now() + Duration::from_secs(15) ) .await, "dropped link should report Connecting (down) then Connected (recovered)" ); // Audio must flow over the rebuilt link. Give the freshly-registered send // path a beat, then stream frames and confirm they reach the peer on conn2. tokio::time::sleep(Duration::from_millis(200)).await; let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap(); for seq in 0..30u32 { dialer.transport.broadcast(packet(&mut enc, seq)); tokio::time::sleep(Duration::from_millis(5)).await; } let received = count_audio( &conn2, 25, tokio::time::Instant::now() + Duration::from_secs(3), ) .await; assert!( received >= 20, "audio should resume after reconnect; got {received} frames" ); } /// A graceful close (our goodbye code `1`) must report `Left` and NOT reconnect — /// the opposite of `dialer_reconnects_after_link_drops`, which closes with code 0 /// (a transient drop). This is what lets a clicked "disconnect" evict the peer /// promptly instead of leaving it stuck "reconnecting". Mirrors the reconnect /// test's harness but asserts the supervisor stops rather than re-dials. #[tokio::test] async fn dialer_reports_left_on_graceful_close() { let s1 = iroh::SecretKey::generate(); let s2 = iroh::SecretKey::generate(); let (dialer_secret, peer_secret) = if s1.public().to_string() < s2.public().to_string() { (s1, s2) } else { (s2, s1) }; let dialer = spawn_node_with_key(dialer_secret).await; let mut peer = spawn_capture_peer(peer_secret).await; dialer.lookup.add_endpoint_info(peer.endpoint.addr()); peer.lookup.add_endpoint_info(dialer.endpoint.addr()); let mut conn_events = dialer .transport .subscribe_conn_events() .await .expect("subscribe dialer conn events"); dialer.transport.connect_peer(peer.endpoint.addr()).await; let conn1 = tokio::time::timeout(Duration::from_secs(10), peer.conns_rx.recv()) .await .expect("timed out awaiting initial connection") .expect("connection channel closed"); assert!( await_reconnect( &mut conn_events, tokio::time::Instant::now() + Duration::from_secs(10) ) .await, "initial link should report Connecting then Connected" ); // Close with the goodbye code (1) — an intentional leave, not a blip. conn1.close(1u32.into(), b"leave"); drop(conn1); // The supervisor must surface `Left` (and must NOT try to reconnect: any // Connecting/Connected here would be the bug). The peer also must not see a // re-dial. let deadline = tokio::time::Instant::now() + Duration::from_secs(10); match tokio::time::timeout_at(deadline, conn_events.recv()).await { Ok(Some(ConnEvent::Left(id))) => { assert_eq!(id, peer.endpoint.id(), "Left should name the peer"); } Ok(Some(ConnEvent::Connecting(_))) | Ok(Some(ConnEvent::Connected(_))) => { panic!("a graceful close must not trigger a reconnect"); } Ok(None) => panic!("conn events channel closed unexpectedly"), Err(_) => panic!("timed out waiting for Left after graceful close"), } // And no re-dial reaches the peer within a short window. let redial = tokio::time::timeout(Duration::from_secs(2), peer.conns_rx.recv()).await; assert!( redial.is_err(), "supervisor must not re-dial after a graceful leave" ); } #[tokio::test] async fn dialer_connects_and_reconnects_without_an_address_lookup() { // Regression for the redial-loop bug: a transient drop that also triggers a // gossip Leave/NeighborDown purges the peer from the address lookup, so a // dialer that resolves by bare id (the old behavior) has nothing to dial and // loops forever. The fix retains the peer's full address in the transport and // dials it directly. To prove the retained address — not the lookup — is what // carries the dial, we never seed the dialer's lookup with the peer at all // (and disable the relay), so a bare-id dial could not resolve an address for // either the initial connect or the reconnect; only `connect_peer`'s retained // EndpointAddr makes them possible. let s1 = iroh::SecretKey::generate(); let s2 = iroh::SecretKey::generate(); let (dialer_secret, peer_secret) = if s1.public().to_string() < s2.public().to_string() { (s1, s2) } else { (s2, s1) }; let dialer = spawn_node_with_key(dialer_secret).await; let mut peer = spawn_capture_peer(peer_secret).await; let dialer_id = dialer.endpoint.id(); let peer_id = peer.endpoint.id(); assert!(dialer_id.to_string() < peer_id.to_string()); // Deliberately leave the dialer's lookup empty for the peer. (The peer side // only ever accepts, so it needs no addressing for the dialer.) let mut conn_events = dialer .transport .subscribe_conn_events() .await .expect("subscribe dialer conn events"); // The full address handed here is the dialer's only way to reach the peer. dialer.transport.connect_peer(peer.endpoint.addr()).await; let conn1 = tokio::time::timeout(Duration::from_secs(10), peer.conns_rx.recv()) .await .expect("timed out awaiting initial connection (retained address path)") .expect("connection channel closed"); assert!( await_reconnect( &mut conn_events, tokio::time::Instant::now() + Duration::from_secs(10) ) .await, "initial link should report Connecting then Connected" ); // Drop the live link from the peer side so the dialer must reconnect — again // with no lookup to fall back on. conn1.close(0u32.into(), b"transient drop"); drop(conn1); let conn2 = tokio::time::timeout(Duration::from_secs(15), peer.conns_rx.recv()) .await .expect("timed out awaiting reconnect (retained address path)") .expect("connection channel closed"); assert!( await_reconnect( &mut conn_events, tokio::time::Instant::now() + Duration::from_secs(15) ) .await, "reconnect should report Connecting then Connected with no lookup at all" ); // Confirm the rebuilt link actually carries audio. tokio::time::sleep(Duration::from_millis(200)).await; let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap(); for seq in 0..30u32 { dialer.transport.broadcast(packet(&mut enc, seq)); tokio::time::sleep(Duration::from_millis(5)).await; } let received = count_audio( &conn2, 25, tokio::time::Instant::now() + Duration::from_secs(3), ) .await; assert!( received >= 20, "audio should resume after reconnecting via the retained address; got {received} frames" ); }