Personal playlist on a dedicated music player (browse/play/prev/next/ seek/volume/reorder/remove, .pls/.m3u import), plus per-person shared listening: broadcast your track over presence, peers tune in and stream it point-to-point over the files plane. Playback is timeline-synced (play/pause/skip/seek mirror with no drift) with gapless prefetch of the next track and independent per-source volume per listener. In the 3-column layout the playlist gets its own card stacked under the chat, with a resizable divider and its own scrollbar; other layouts keep it in the Controls panel. Breaking wire change: gossip protocol v5 (presence gains music fields), so 0.6.0 peers cannot share a swarm with 0.5.x. Version bumped 0.5.1 -> 0.6.0; CHANGELOG updated. Untrusted-input handling: broadcast track name sanitized and size cap-checked at gossip ingest, fetched bytes confirmed audio before decode, only the descriptor rides gossip (bytes go point-to-point, one fetch in flight). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
357 lines
12 KiB
Rust
357 lines
12 KiB
Rust
//! Phase-0 spike for post-grace gossip recovery.
|
|
//!
|
|
//! These tests prove that `GossipSender::join_peers` can restore an existing
|
|
//! topic subscription after the other peer drops and rejoins without its own
|
|
//! bootstrap target. The second case disables relays, clears the surviving
|
|
//! node's lookup, and moves the peer to a fresh endpoint address so only the
|
|
//! retained full address passed to `rebootstrap_peers` can drive recovery.
|
|
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use iroh::address_lookup::memory::MemoryLookup;
|
|
use iroh::endpoint::presets;
|
|
use iroh::protocol::Router;
|
|
use iroh::{Endpoint, EndpointAddr, EndpointId, RelayMode, SecretKey};
|
|
use iroh_gossip::net::Gossip;
|
|
use tokio::sync::mpsc;
|
|
|
|
use peerspeak::network::gossip::IrohGossipState;
|
|
use peerspeak::network::{PeerSpeakTicket, PeerState, RoomEvent, RoomState};
|
|
|
|
const EVENT_TIMEOUT: Duration = Duration::from_secs(10);
|
|
|
|
struct GossipNode {
|
|
endpoint: Endpoint,
|
|
lookup: MemoryLookup,
|
|
room: Arc<IrohGossipState>,
|
|
_router: Router,
|
|
}
|
|
|
|
async fn spawn_node(secret: SecretKey) -> GossipNode {
|
|
let lookup = MemoryLookup::new();
|
|
let endpoint = Endpoint::builder(presets::Minimal)
|
|
.secret_key(secret.clone())
|
|
.relay_mode(RelayMode::Disabled)
|
|
.address_lookup(lookup.clone())
|
|
.bind()
|
|
.await
|
|
.expect("bind gossip endpoint");
|
|
let gossip = Gossip::builder().spawn(endpoint.clone());
|
|
let router = Router::builder(endpoint.clone())
|
|
.accept(iroh_gossip::net::GOSSIP_ALPN, gossip.clone())
|
|
.spawn();
|
|
let room = Arc::new(IrohGossipState::new(
|
|
endpoint.clone(),
|
|
gossip,
|
|
lookup.clone(),
|
|
secret,
|
|
));
|
|
|
|
GossipNode {
|
|
endpoint,
|
|
lookup,
|
|
room,
|
|
_router: router,
|
|
}
|
|
}
|
|
|
|
fn state(name: &str, addr: EndpointAddr) -> PeerState {
|
|
PeerState {
|
|
name: name.to_string(),
|
|
is_muted: false,
|
|
addr,
|
|
sharing: None,
|
|
avatar: Default::default(),
|
|
game: None,
|
|
music: None,
|
|
}
|
|
}
|
|
|
|
async fn await_joined(rx: &mut mpsc::Receiver<RoomEvent>, peer_id: EndpointId) -> PeerState {
|
|
let deadline = tokio::time::Instant::now() + EVENT_TIMEOUT;
|
|
loop {
|
|
match tokio::time::timeout_at(deadline, rx.recv()).await {
|
|
Ok(Some(RoomEvent::PeerJoined(id, peer_state))) if id == peer_id => return peer_state,
|
|
Ok(Some(_)) => continue,
|
|
Ok(None) => panic!("room event channel closed while waiting for PeerJoined"),
|
|
Err(_) => panic!("timed out waiting for PeerJoined({peer_id:?})"),
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn await_joined_all(rx: &mut mpsc::Receiver<RoomEvent>, peer_ids: &[EndpointId]) {
|
|
let deadline = tokio::time::Instant::now() + EVENT_TIMEOUT;
|
|
let mut remaining = peer_ids.to_vec();
|
|
while !remaining.is_empty() {
|
|
match tokio::time::timeout_at(deadline, rx.recv()).await {
|
|
Ok(Some(RoomEvent::PeerJoined(id, _))) => remaining.retain(|wanted| *wanted != id),
|
|
Ok(Some(_)) => {}
|
|
Ok(None) => panic!("room event channel closed while waiting for PeerJoined set"),
|
|
Err(_) => panic!("timed out waiting for PeerJoined set: {remaining:?}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn await_left(rx: &mut mpsc::Receiver<RoomEvent>, peer_id: EndpointId) {
|
|
let deadline = tokio::time::Instant::now() + EVENT_TIMEOUT;
|
|
loop {
|
|
match tokio::time::timeout_at(deadline, rx.recv()).await {
|
|
Ok(Some(RoomEvent::PeerLeft(id))) if id == peer_id => return,
|
|
Ok(Some(_)) => continue,
|
|
Ok(None) => panic!("room event channel closed while waiting for PeerLeft"),
|
|
Err(_) => panic!("timed out waiting for PeerLeft({peer_id:?})"),
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn await_absent(room: &IrohGossipState, peer_id: EndpointId) {
|
|
let deadline = tokio::time::Instant::now() + EVENT_TIMEOUT;
|
|
loop {
|
|
if !room.active_peers().iter().any(|(id, _)| *id == peer_id) {
|
|
return;
|
|
}
|
|
assert!(
|
|
tokio::time::Instant::now() < deadline,
|
|
"timed out waiting for peer to leave the roster"
|
|
);
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
}
|
|
}
|
|
|
|
fn ticket(host_addr: EndpointAddr) -> String {
|
|
PeerSpeakTicket {
|
|
host_addr,
|
|
topic_id: rand::random(),
|
|
name: "rebootstrap-spike".to_string(),
|
|
}
|
|
.to_string()
|
|
}
|
|
|
|
async fn establish_room(
|
|
a: &GossipNode,
|
|
b: &GossipNode,
|
|
ticket: &str,
|
|
events_a: &mut mpsc::Receiver<RoomEvent>,
|
|
) {
|
|
// B is the ticket host. Its own bootstrap set is empty; A is the only side
|
|
// that initially dials, which is also how the recovery setup is controlled.
|
|
b.room
|
|
.join(ticket, state("Bob", b.endpoint.addr()), vec![])
|
|
.await
|
|
.expect("host joins topic");
|
|
a.room
|
|
.join(ticket, state("Alice", a.endpoint.addr()), vec![])
|
|
.await
|
|
.expect("client joins topic");
|
|
let joined = await_joined(events_a, b.endpoint.id()).await;
|
|
assert_eq!(joined.name, "Bob");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rebootstrap_restores_roster_on_existing_subscription() {
|
|
let a = spawn_node(SecretKey::generate()).await;
|
|
let b = spawn_node(SecretKey::generate()).await;
|
|
let ticket = ticket(b.endpoint.addr());
|
|
let mut events_a = a.room.subscribe_events().await.expect("subscribe A events");
|
|
|
|
establish_room(&a, &b, &ticket, &mut events_a).await;
|
|
|
|
// Drop only B's topic subscription. A stays subscribed. B then rejoins as
|
|
// the ticket host, so compute_bootstrap removes self and B has nobody to dial.
|
|
b.room.leave().await.expect("B leaves topic");
|
|
await_absent(&a.room, b.endpoint.id()).await;
|
|
b.room
|
|
.join(&ticket, state("Bob recovered", b.endpoint.addr()), vec![])
|
|
.await
|
|
.expect("B rejoins without bootstrap peers");
|
|
|
|
tokio::time::sleep(Duration::from_millis(250)).await;
|
|
assert!(
|
|
!a.room
|
|
.active_peers()
|
|
.iter()
|
|
.any(|(id, _)| *id == b.endpoint.id()),
|
|
"B must not recover before A explicitly re-bootstraps it"
|
|
);
|
|
|
|
a.room
|
|
.rebootstrap_peers(vec![b.endpoint.addr()])
|
|
.await
|
|
.expect("targeted gossip re-bootstrap");
|
|
|
|
let recovered = await_joined(&mut events_a, b.endpoint.id()).await;
|
|
assert_eq!(recovered.name, "Bob recovered");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rebootstrap_uses_retained_full_address_with_empty_lookup() {
|
|
let a = spawn_node(SecretKey::generate()).await;
|
|
let b_secret = SecretKey::generate();
|
|
let b = spawn_node(b_secret.clone()).await;
|
|
let b_id = b.endpoint.id();
|
|
let old_b_addr = b.endpoint.addr();
|
|
let ticket = ticket(old_b_addr.clone());
|
|
let mut events_a = a.room.subscribe_events().await.expect("subscribe A events");
|
|
|
|
establish_room(&a, &b, &ticket, &mut events_a).await;
|
|
b.room.leave().await.expect("old B leaves topic");
|
|
await_absent(&a.room, b_id).await;
|
|
|
|
// Move the same authenticated identity to a newly-bound direct-only endpoint.
|
|
// The old cached path is now dead; the new full address is the only valid one.
|
|
b.endpoint.close().await;
|
|
drop(b);
|
|
tokio::time::sleep(Duration::from_millis(250)).await;
|
|
let b_rebound = spawn_node(b_secret).await;
|
|
let new_b_addr = b_rebound.endpoint.addr();
|
|
assert_eq!(new_b_addr.id, b_id, "identity must survive the rebind");
|
|
assert_ne!(
|
|
new_b_addr, old_b_addr,
|
|
"rebound peer must have a fresh address"
|
|
);
|
|
|
|
b_rebound
|
|
.room
|
|
.join(&ticket, state("Bob rebound", new_b_addr.clone()), vec![])
|
|
.await
|
|
.expect("rebound host joins without bootstrap peers");
|
|
|
|
// Remove the stale lookup entry. `rebootstrap_peers` must seed the retained
|
|
// new full address before asking gossip to join the peer by id.
|
|
a.lookup.remove_endpoint_info(b_id);
|
|
assert!(
|
|
a.lookup.get_endpoint_info(b_id).is_none(),
|
|
"A lookup starts empty for B"
|
|
);
|
|
tokio::time::sleep(Duration::from_millis(250)).await;
|
|
assert!(
|
|
!a.room.active_peers().iter().any(|(id, _)| *id == b_id),
|
|
"rebound B must not be rediscovered without the retained address"
|
|
);
|
|
|
|
a.room
|
|
.rebootstrap_peers(vec![new_b_addr])
|
|
.await
|
|
.expect("retained-address gossip re-bootstrap");
|
|
assert!(
|
|
a.lookup.get_endpoint_info(b_id).is_some(),
|
|
"re-bootstrap must restore B's address to the lookup"
|
|
);
|
|
|
|
let recovered = await_joined(&mut events_a, b_id).await;
|
|
assert_eq!(recovered.name, "Bob rebound");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn demoted_peer_requires_a_fresh_signed_announce_to_rejoin() {
|
|
let a = spawn_node(SecretKey::generate()).await;
|
|
let b = spawn_node(SecretKey::generate()).await;
|
|
let ticket = ticket(b.endpoint.addr());
|
|
let mut events_a = a.room.subscribe_events().await.expect("subscribe A events");
|
|
|
|
establish_room(&a, &b, &ticket, &mut events_a).await;
|
|
a.room.mark_peer_disconnected(b.endpoint.id());
|
|
assert!(
|
|
!a.room
|
|
.active_peers()
|
|
.iter()
|
|
.any(|(id, _)| *id == b.endpoint.id()),
|
|
"demotion must revoke live roster membership"
|
|
);
|
|
|
|
b.room
|
|
.update_self_state(state("Bob authenticated again", b.endpoint.addr()))
|
|
.await
|
|
.expect("broadcast fresh signed announce");
|
|
|
|
let recovered = await_joined(&mut events_a, b.endpoint.id()).await;
|
|
assert_eq!(recovered.name, "Bob authenticated again");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn signed_leave_after_demotion_still_emits_peer_left() {
|
|
let a = spawn_node(SecretKey::generate()).await;
|
|
let b = spawn_node(SecretKey::generate()).await;
|
|
let ticket = ticket(b.endpoint.addr());
|
|
let mut events_a = a.room.subscribe_events().await.expect("subscribe A events");
|
|
|
|
establish_room(&a, &b, &ticket, &mut events_a).await;
|
|
a.room.mark_peer_disconnected(b.endpoint.id());
|
|
|
|
b.room
|
|
.leave()
|
|
.await
|
|
.expect("broadcast signed Leave after demotion");
|
|
await_left(&mut events_a, b.endpoint.id()).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn targeted_rebootstrap_preserves_healthy_peer_in_three_peer_room() {
|
|
let a = spawn_node(SecretKey::generate()).await;
|
|
let b = spawn_node(SecretKey::generate()).await;
|
|
let c_secret = SecretKey::generate();
|
|
let c = spawn_node(c_secret.clone()).await;
|
|
let c_id = c.endpoint.id();
|
|
let ticket = ticket(c.endpoint.addr());
|
|
let mut events_a = a.room.subscribe_events().await.expect("subscribe A events");
|
|
let mut events_b = b.room.subscribe_events().await.expect("subscribe B events");
|
|
|
|
c.room
|
|
.join(&ticket, state("Carol", c.endpoint.addr()), vec![])
|
|
.await
|
|
.expect("C hosts topic");
|
|
b.room
|
|
.join(&ticket, state("Bob", b.endpoint.addr()), vec![])
|
|
.await
|
|
.expect("B joins C");
|
|
await_joined(&mut events_b, c_id).await;
|
|
a.room
|
|
.join(&ticket, state("Alice", a.endpoint.addr()), vec![])
|
|
.await
|
|
.expect("A joins C");
|
|
await_joined_all(&mut events_a, &[b.endpoint.id(), c_id]).await;
|
|
await_joined(&mut events_b, a.endpoint.id()).await;
|
|
|
|
// Remove only C. A and B keep their existing topic subscriptions and remain
|
|
// mutually present while C is rebound to a fresh address.
|
|
c.endpoint.close().await;
|
|
drop(c);
|
|
a.room.mark_peer_disconnected(c_id);
|
|
b.room.mark_peer_disconnected(c_id);
|
|
let c_rebound = spawn_node(c_secret).await;
|
|
let rebound_addr = c_rebound.endpoint.addr();
|
|
c_rebound
|
|
.room
|
|
.join(
|
|
&ticket,
|
|
state("Carol recovered", rebound_addr.clone()),
|
|
vec![],
|
|
)
|
|
.await
|
|
.expect("rebound C rejoins as host without bootstrap");
|
|
|
|
a.room
|
|
.rebootstrap_peers(vec![rebound_addr])
|
|
.await
|
|
.expect("A targets only C for recovery");
|
|
let recovered = await_joined(&mut events_a, c_id).await;
|
|
assert_eq!(recovered.name, "Carol recovered");
|
|
await_joined(&mut events_b, c_id).await;
|
|
|
|
assert!(
|
|
a.room
|
|
.active_peers()
|
|
.iter()
|
|
.any(|(id, _)| *id == b.endpoint.id()),
|
|
"healthy B must remain present at A throughout C recovery"
|
|
);
|
|
assert!(
|
|
b.room
|
|
.active_peers()
|
|
.iter()
|
|
.any(|(id, _)| *id == a.endpoint.id()),
|
|
"healthy A must remain present at B throughout C recovery"
|
|
);
|
|
}
|