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
+13 -2
View File
@@ -1,4 +1,4 @@
use iroh::EndpointId;
use iroh::{EndpointId, EndpointAddr};
use bytes::Bytes;
use thiserror::Error;
use tokio::sync::mpsc::Receiver;
@@ -133,7 +133,18 @@ pub trait NetworkTransport: Send + Sync {
#[async_trait]
pub trait RoomState: Send + Sync {
/// Joins a room using a gossip ticket string and announces our state.
async fn join(&self, ticket: &str, self_state: PeerState) -> Result<(), NetError>;
///
/// `extra_bootstrap` are additional peer addresses to dial when entering the
/// gossip swarm, on top of the ticket's host. This is what lets the ROOM
/// CREATOR rejoin a room they left: their own ticket lists only themselves as
/// host, so without retained peers they'd have no dial target and never
/// re-enter the swarm (bug A8). Callers with no prior peers pass `vec![]`.
async fn join(
&self,
ticket: &str,
self_state: PeerState,
extra_bootstrap: Vec<EndpointAddr>,
) -> Result<(), NetError>;
/// Updates our local state (e.g. when user mutes/unmutes) and broadcasts it.
async fn update_self_state(&self, self_state: PeerState) -> Result<(), NetError>;