From a24341c36195d6730624ecba326455ebe2625779 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Thu, 21 May 2026 20:32:03 +0200 Subject: [PATCH] feat(relay): notify peers when room members leave The relay now sends a reliable PeerLeft control event to remaining room peers after removing the departed peer from session and room state. Normal connection closure is reported as Normal, while malformed-datagram disconnects are reported as ProtocolError. This completes the first relay-side lifecycle event pair. Delivery remains best-effort and client-side event consumption is intentionally left for a separate slice. Test Plan: - cargo fmt --check - cargo test -p lanparty-relay forwards_ethernet_datagrams_between_joined_peers \ -- --nocapture - 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 Refs: PLAN.md --- README.md | 6 +-- crates/lanparty-relay/src/server.rs | 72 +++++++++++++++++++++++++---- 2 files changed, 65 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 265be36..64f65f0 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ Public relay binary and relay-owned room state: - one gateway per room, duplicate client MAC rejection, and room limits - stable effective room MTU chosen before Ethernet datagrams flow - live Ethernet datagram forwarding with no ingress reflection -- reliable `PeerJoined` notifications to existing room peers +- reliable `PeerJoined`/`PeerLeft` notifications to existing room peers - L2 safety filters for jumbo, switch-control, DHCP-server, and IPv6-RA frames - client broadcast/multicast, unknown-unicast, and total bandwidth limiting - malformed peer datagram disconnect threshold @@ -112,8 +112,8 @@ certificate handling remains future work. Ethernet forwarding decisions are logged with room, peer, MAC, ethertype, action, drop reason, and target count. 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, the relay sends a reliable `PeerJoined` control event to -peers that were already present in the room. +When a peer joins or leaves, the relay sends a reliable lifecycle control event +to peers that are still present in the room. ## Gateway diff --git a/crates/lanparty-relay/src/server.rs b/crates/lanparty-relay/src/server.rs index a4f993e..84cfd69 100644 --- a/crates/lanparty-relay/src/server.rs +++ b/crates/lanparty-relay/src/server.rs @@ -3,7 +3,7 @@ use std::{fs, net::SocketAddr, path::Path, sync::Arc}; use anyhow::{Context, Result, anyhow}; use bytes::Bytes; use lanparty_ctrl::{ - CONTROL_LENGTH_PREFIX_LEN, ControlCodecError, ControlMessage, EndpointHello, + CONTROL_LENGTH_PREFIX_LEN, ControlCodecError, ControlMessage, DisconnectReason, EndpointHello, MAX_CONTROL_MESSAGE_LEN, PeerInfo, RELAY_ALPN, Reject, RejectReason, Role, RoomCode, ServerWelcome, decode_control_frame, encode_control_message, }; @@ -65,6 +65,28 @@ enum PeerDatagramOutcome { Malformed, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct PeerClose { + reason: DisconnectReason, + message: String, +} + +impl PeerClose { + fn normal(message: impl Into) -> Self { + Self { + reason: DisconnectReason::Normal, + message: message.into(), + } + } + + fn protocol_error(message: impl Into) -> Self { + Self { + reason: DisconnectReason::ProtocolError, + message: message.into(), + } + } +} + #[derive(Debug, Default)] struct MalformedDatagramTracker { count: usize, @@ -203,13 +225,20 @@ async fn handle_incoming_connection( accepted.welcome.effective_tap_mtu() ); - let close_reason = run_peer_datagrams(&rooms, &sessions, &accepted, &connection).await; - leave_peer(&rooms, &sessions, &accepted.room, accepted.peer.peer_id()).await?; + let close = run_peer_datagrams(&rooms, &sessions, &accepted, &connection).await; + let leave = leave_peer(&rooms, &sessions, &accepted.room, accepted.peer.peer_id()).await?; + notify_peer_left( + &sessions, + &accepted.room, + leave.peer().peer_id(), + close.reason.clone(), + ) + .await; println!( "peer {} left room {}: {}", accepted.peer.peer_id(), accepted.room, - close_reason + close.message ); Ok(Some(accepted)) @@ -268,7 +297,7 @@ async fn run_peer_datagrams( sessions: &Arc>>, accepted: &AcceptedPeer, connection: &quinn::Connection, -) -> String { +) -> PeerClose { let mut malformed_tracker = MalformedDatagramTracker::default(); loop { @@ -279,7 +308,7 @@ async fn run_peer_datagrams( Ok(PeerDatagramOutcome::Malformed) => { if let Some(reason) = malformed_tracker.record_malformed() { connection.close(0_u32.into(), reason.as_bytes()); - return reason; + return PeerClose::protocol_error(reason); } } Err(error) => { @@ -291,7 +320,7 @@ async fn run_peer_datagrams( } } } - Err(error) => return error.to_string(), + Err(error) => return PeerClose::normal(error.to_string()), } } } @@ -450,6 +479,22 @@ async fn notify_peer_joined( } } +async fn notify_peer_left( + sessions: &Arc>>, + room: &RoomCode, + peer_id: u32, + reason: DisconnectReason, +) { + let target_sessions = collect_room_sessions_except(sessions, room, peer_id).await; + let message = ControlMessage::PeerLeft { peer_id, reason }; + + for target in target_sessions { + if let Err(error) = send_control_event(&target.connection, &message).await { + eprintln!("failed to notify room {room} about peer {peer_id} leaving: {error:#}"); + } + } +} + async fn collect_room_sessions_except( sessions: &Arc>>, room: &RoomCode, @@ -579,18 +624,18 @@ async fn leave_peer( sessions: &Arc>>, room: &RoomCode, peer_id: u32, -) -> Result<()> { +) -> Result { sessions .lock() .await .remove(&PeerKey::new(room.clone(), peer_id)); - rooms + let leave = rooms .lock() .await .leave(room, peer_id) .with_context(|| format!("failed to remove peer {peer_id} from room {room}"))?; - Ok(()) + Ok(leave) } fn write_development_certificate(path: &Path, certificate: &CertificateDer<'_>) -> Result<()> { @@ -883,6 +928,13 @@ mod tests { assert_eq!(first_welcome.room_id(), second_welcome.room_id()); first_connection.close(0_u32.into(), b"test complete"); + let event = read_control_event(&second_connection).await; + let ControlMessage::PeerLeft { peer_id, reason } = event else { + panic!("expected peer left event"); + }; + assert_eq!(peer_id, first_welcome.peer_id()); + assert_eq!(reason, DisconnectReason::Normal); + second_connection.close(0_u32.into(), b"test complete"); first_endpoint.wait_idle().await; second_endpoint.wait_idle().await;