fix(net): room creator can rejoin a room they left (A8)

The gossip bootstrap list was derived solely from the ticket: a client
dialed the host, but "we are the host" produced an EMPTY list. So when the
room CREATOR rejoined their own room (their ticket names themselves as host)
they dialed nobody and never re-entered the swarm — the remaining peer stayed
stuck until it too left and rejoined. (First 2-human field test, 2026-06-14;
user-confirmed call-breaking, P1.)

Fix: retain the peers seen in the current room across leave (core
`known_peers`, updated by the event task; reset only when the joined ticket
changes) and pass them to `RoomState::join` as extra bootstrap targets. The
new pure `compute_bootstrap` seam unions the ticket host + retained peers,
drops self, and de-dups; address resolution rides the persistent lookup.

- `RoomState::join` gains `extra_bootstrap: Vec<EndpointAddr>` (test_net
  callers pass vec![]).
- +4 unit tests on `compute_bootstrap`, incl. the host-rejoin regression case.

Tests-green (208 lib + 6 + 4 integration), clippy clean. NOT yet 2-machine
field-verified — needs a live host leave→rejoin call.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 14:57:37 -04:00
co-authored by Claude Opus 4.8
parent 88e0adec5d
commit 7c8f0114f7
4 changed files with 138 additions and 19 deletions
+89 -12
View File
@@ -1,5 +1,5 @@
use crate::network::{RoomState, NetError, PeerState, RoomEvent, PeerSpeakTicket};
use iroh::{Endpoint, EndpointId, SecretKey, Signature};
use iroh::{Endpoint, EndpointAddr, EndpointId, SecretKey, Signature};
use iroh_gossip::net::Gossip;
use iroh_gossip::proto::TopicId;
use tokio::sync::mpsc;
@@ -102,6 +102,28 @@ fn verify_gossip(
Ok(())
}
/// Compute the gossip bootstrap peer set for a (re)join: the ticket's host plus
/// every peer we already knew about (`extra`), with self removed and ids
/// de-duplicated. Without the retained `extra` peers, the ROOM CREATOR rejoining
/// their own room would have an empty list (their ticket names only themselves as
/// host) and so never re-enter the swarm — that was bug A8. Pure → unit-testable.
fn compute_bootstrap(
self_id: EndpointId,
ticket_host: EndpointId,
extra: &[EndpointAddr],
) -> Vec<EndpointId> {
let mut out: Vec<EndpointId> = Vec::new();
if ticket_host != self_id {
out.push(ticket_host);
}
for addr in extra {
if addr.id != self_id && !out.contains(&addr.id) {
out.push(addr.id);
}
}
out
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum GossipMessage {
Announce(PeerState),
@@ -157,7 +179,12 @@ impl IrohGossipState {
#[async_trait]
impl RoomState for IrohGossipState {
async fn join(&self, ticket_str: &str, self_state: PeerState) -> Result<(), NetError> {
async fn join(
&self,
ticket_str: &str,
self_state: PeerState,
extra_bootstrap: Vec<EndpointAddr>,
) -> Result<(), NetError> {
crate::log_msg(&format!("RoomState::join: self_id={:?}, self_name={:?}, ticket={}", self_state.addr.id, self_state.name, ticket_str));
let ticket = ticket_str.parse::<PeerSpeakTicket>()?;
let topic_id = TopicId::from_bytes(ticket.topic_id);
@@ -167,18 +194,20 @@ impl RoomState for IrohGossipState {
// Stop any currently running topic
let _ = self.leave().await;
// Add the host to the address book
// Make every dial target resolvable: the ticket host plus any retained
// peers handed in (their addresses may have aged out of a fresh endpoint's
// book even though the persistent lookup usually still holds them).
self.address_lookup.add_endpoint_info(ticket.host_addr.clone());
for addr in &extra_bootstrap {
self.address_lookup.add_endpoint_info(addr.clone());
}
// Join the gossip topic. If we are the host, bootstrap list will be empty
// or contain ourselves (which is fine), but let's bootstrap to the ticket host.
let bootstrap_peers = if ticket.host_addr.id == self_state.addr.id {
crate::log_msg("We are the host. Bootstrap peers list is empty.");
vec![]
} else {
crate::log_msg(&format!("We are a client. Bootstrapping to host ID={:?}", ticket.host_addr.id));
vec![ticket.host_addr.id]
};
// Bootstrap to the ticket host AND every retained peer (minus self). The
// retained peers are what let the room creator rejoin a room they left —
// their own ticket names only themselves as host (bug A8).
let bootstrap_peers =
compute_bootstrap(self_state.addr.id, ticket.host_addr.id, &extra_bootstrap);
crate::log_msg(&format!("Bootstrap peers for join: {:?}", bootstrap_peers));
let gossip_topic = self.gossip.subscribe(topic_id, bootstrap_peers).await
.map_err(|e| {
@@ -467,6 +496,54 @@ mod tests {
}
}
fn fresh_id() -> EndpointId {
SecretKey::generate().public()
}
fn addr_of(id: EndpointId) -> EndpointAddr {
EndpointAddr::from(id)
}
#[test]
fn bootstrap_client_dials_host() {
// A non-host (client) with no retained peers dials just the ticket host.
let me = fresh_id();
let host = fresh_id();
assert_eq!(compute_bootstrap(me, host, &[]), vec![host]);
}
#[test]
fn bootstrap_host_rejoin_dials_retained_peers() {
// The A8 regression: the room creator's ticket names themselves as host,
// so the ticket-host contributes nothing — the retained peer is the only
// (and essential) dial target. An empty result here would reproduce A8.
let me = fresh_id();
let peer = fresh_id();
let bootstrap = compute_bootstrap(me, /* ticket_host = */ me, &[addr_of(peer)]);
assert_eq!(bootstrap, vec![peer]);
}
#[test]
fn bootstrap_host_rejoin_with_no_retained_peers_is_empty() {
// Host rejoin before anyone was ever seen: nothing to dial (unavoidable),
// but it must not contain self.
let me = fresh_id();
assert!(compute_bootstrap(me, me, &[]).is_empty());
}
#[test]
fn bootstrap_excludes_self_and_dedups_host() {
// Self is never dialed (even if it shows up among retained peers), and a
// peer that equals the ticket host isn't listed twice.
let me = fresh_id();
let host = fresh_id();
let other = fresh_id();
let extra = [addr_of(me), addr_of(host), addr_of(other)];
let bootstrap = compute_bootstrap(me, host, &extra);
assert_eq!(bootstrap, vec![host, other]);
assert!(!bootstrap.contains(&me));
}
#[test]
fn test_gossip_message_leave_round_trip() {
let original = GossipMessage::Leave;