From f82b658e3cfdf1aaee863bdb3f62b26f77f2239b Mon Sep 17 00:00:00 2001 From: ddidderr Date: Fri, 22 May 2026 08:17:56 +0200 Subject: [PATCH] fix(relay): seed gateways before join notifications The relay used one accepted-peer handshake order for clients and gateways: register the peer, notify existing peers, send the welcome, then send catch-up events to the new peer. That is correct for a client joining an existing gateway because the gateway must learn the client's MAC before the client can send frames. The same ordering is risky for a gateway joining a room that already has clients. Existing clients could learn that the gateway is available before the gateway has received the existing client MAC table. Reverse that part of the sequence for gateways: send the gateway welcome, send existing peer catch-up events to the gateway, then notify clients that the gateway joined. Keep client joins on the old ordering, and keep handshake-failure cleanup quiet for gateway welcome failures because clients have not been notified yet. Test Plan: - cargo fmt --check - cargo test -p lanparty-relay - cargo clippy -p lanparty-relay --all-targets -- -D warnings - cargo test --workspace - cargo clippy --workspace --all-targets -- -D warnings - git diff --check - git diff --cached --check Refs: MVP gateway lifecycle ordering --- README.md | 8 +- crates/lanparty-relay/src/server.rs | 118 ++++++++++++++++++++++------ 2 files changed, 101 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 72f7379..611db6f 100644 --- a/README.md +++ b/README.md @@ -153,9 +153,11 @@ Unknown unicast from a client is forwarded only to the gateway port; unknown unicast from the gateway is dropped instead of flooded to every remote client. When a peer joins or leaves, the relay sends a reliable lifecycle control event to peers that are still present in the room. Newly joined peers also receive -`PeerJoined` events for peers that were already present. Accepted joins notify -existing peers before the joining peer receives its welcome, so gateways can -seed client MAC state before a freshly accepted client starts sending frames. +`PeerJoined` events for peers that were already present. When a client joins, +the relay notifies existing peers before the client receives its welcome, so +gateways can seed client MAC state before that client starts sending frames. +When a gateway joins, the relay gives the gateway the current client list +before notifying clients that the gateway is available. ### MVP Trust Model diff --git a/crates/lanparty-relay/src/server.rs b/crates/lanparty-relay/src/server.rs index 3f91866..2184c46 100644 --- a/crates/lanparty-relay/src/server.rs +++ b/crates/lanparty-relay/src/server.rs @@ -76,6 +76,28 @@ enum PeerControlOutcome { Close(PeerClose), } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AcceptedHandshakeStep { + NotifyJoinedPeerToExisting, + SendWelcome, + NotifyExistingPeersToJoined, +} + +const CLIENT_HANDSHAKE_STEPS: &[AcceptedHandshakeStep] = &[ + // Existing peers, especially the gateway, need the new client's MAC before + // the client can receive its welcome and send Ethernet datagrams. + AcceptedHandshakeStep::NotifyJoinedPeerToExisting, + AcceptedHandshakeStep::SendWelcome, + AcceptedHandshakeStep::NotifyExistingPeersToJoined, +]; +const GATEWAY_HANDSHAKE_STEPS: &[AcceptedHandshakeStep] = &[ + AcceptedHandshakeStep::SendWelcome, + // A newly joined gateway needs the current client MAC table before clients + // learn that a gateway is available and send first traffic. + AcceptedHandshakeStep::NotifyExistingPeersToJoined, + AcceptedHandshakeStep::NotifyJoinedPeerToExisting, +]; + #[derive(Debug, Clone, PartialEq, Eq)] struct PeerClose { reason: DisconnectReason, @@ -274,33 +296,45 @@ async fn accept_control_handshake( .context("failed to read relay control hello")?; let (accepted, response) = build_handshake_response(rooms, connection, frame.as_slice()).await; - if let Some(accepted) = &accepted { - register_peer(sessions, accepted, connection.clone()).await; - // Seed existing peers before the joining peer can send Ethernet datagrams. - notify_peer_joined(sessions, accepted).await; - } + let Some(accepted) = accepted else { + send_control_message(&mut send, &response).await?; - if let Err(error) = send_control_message(&mut send, &response).await { - if let Some(accepted) = &accepted { - let leave = - leave_peer(rooms, sessions, &accepted.room, accepted.peer.peer_id()).await?; - notify_peer_left( - sessions, - &accepted.room, - leave.peer().peer_id(), - DisconnectReason::Normal, - ) - .await; + return Ok(None); + }; + + register_peer(sessions, &accepted, connection.clone()).await; + for step in accepted_handshake_steps(accepted.peer.role()) { + match step { + AcceptedHandshakeStep::NotifyJoinedPeerToExisting => { + notify_peer_joined(sessions, &accepted).await; + } + AcceptedHandshakeStep::SendWelcome => { + if let Err(error) = send_control_message(&mut send, &response).await { + cleanup_accepted_handshake( + rooms, + sessions, + &accepted, + accepted.peer.role() == Role::Client, + ) + .await?; + + return Err(error); + } + } + AcceptedHandshakeStep::NotifyExistingPeersToJoined => { + notify_existing_peers_to_joined_peer(rooms, connection, &accepted).await; + } } - - return Err(error); } - if let Some(accepted) = &accepted { - notify_existing_peers_to_joined_peer(rooms, connection, accepted).await; - } + Ok(Some(accepted)) +} - Ok(accepted) +const fn accepted_handshake_steps(role: Role) -> &'static [AcceptedHandshakeStep] { + match role { + Role::Client => CLIENT_HANDSHAKE_STEPS, + Role::Gateway => GATEWAY_HANDSHAKE_STEPS, + } } async fn register_peer( @@ -319,6 +353,26 @@ async fn register_peer( ); } +async fn cleanup_accepted_handshake( + rooms: &Arc>, + sessions: &Arc>>, + accepted: &AcceptedPeer, + notify_leave: bool, +) -> Result<()> { + let leave = leave_peer(rooms, sessions, &accepted.room, accepted.peer.peer_id()).await?; + if notify_leave { + notify_peer_left( + sessions, + &accepted.room, + leave.peer().peer_id(), + DisconnectReason::Normal, + ) + .await; + } + + Ok(()) +} + async fn run_peer_io( rooms: &Arc>, sessions: &Arc>>, @@ -1143,6 +1197,26 @@ mod tests { assert!(reason.contains("malformed datagrams")); } + #[test] + fn orders_handshake_notifications_by_joining_role() { + assert_eq!( + accepted_handshake_steps(Role::Client), + &[ + AcceptedHandshakeStep::NotifyJoinedPeerToExisting, + AcceptedHandshakeStep::SendWelcome, + AcceptedHandshakeStep::NotifyExistingPeersToJoined, + ] + ); + assert_eq!( + accepted_handshake_steps(Role::Gateway), + &[ + AcceptedHandshakeStep::SendWelcome, + AcceptedHandshakeStep::NotifyExistingPeersToJoined, + AcceptedHandshakeStep::NotifyJoinedPeerToExisting, + ] + ); + } + #[tokio::test] async fn formats_malformed_datagram_log_line() { let rooms = Arc::new(Mutex::new(RoomRegistry::default()));