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
This commit is contained in:
2026-05-22 08:17:56 +02:00
parent f288d3a1a9
commit f82b658e3c
2 changed files with 101 additions and 25 deletions
+5 -3
View File
@@ -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
+96 -22
View File
@@ -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<Mutex<RoomRegistry>>,
sessions: &Arc<Mutex<HashMap<PeerKey, PeerSession>>>,
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<Mutex<RoomRegistry>>,
sessions: &Arc<Mutex<HashMap<PeerKey, PeerSession>>>,
@@ -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()));