feat(ctrl): send graceful disconnects

The relay already accepts post-handshake Disconnect control messages, but the
client and gateway shutdown paths only sent a QUIC application close. That made
normal shutdown indistinguishable from transport closure until the relay
inferred a generic Normal leave.

Client and gateway shutdown now send a best-effort Disconnect message with the
human-readable shutdown reason before closing QUIC. The client-core drain uses
Quinn's runtime timer instead of taking a Tokio runtime dependency. The gateway
uses its existing Tokio runtime and applies the same short drain window on both
explicit shutdown and Ctrl-C in the Linux bridge loop.

The endpoint integration tests now assert that the server receives Disconnect
after the stats stream, which also protects against closing too quickly and
aborting the control stream.

Test Plan:
- cargo fmt --check
- cargo test -p lanparty-client-core \
  connects_to_relay_control_stream_as_client -- --nocapture
- cargo test -p lanparty-gateway \
  connects_to_relay_control_stream_as_gateway -- --nocapture
- cargo test -p lanparty-client-core
- cargo test -p lanparty-gateway
- cargo clippy -p lanparty-client-core --all-targets -- -D warnings
- cargo clippy -p lanparty-gateway --all-targets -- -D warnings
- cargo test --workspace
- cargo clippy --workspace --all-targets -- -D warnings
- git diff --check

Refs: PLAN.md
This commit is contained in:
2026-05-21 21:07:47 +02:00
parent 66d6601d21
commit 546060568b
3 changed files with 107 additions and 11 deletions
+41 -2
View File
@@ -7,6 +7,7 @@
use std::{
fs,
future::poll_fn,
io::ErrorKind,
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
path::{Path, PathBuf},
@@ -14,13 +15,15 @@ use std::{
Arc,
atomic::{AtomicU64, Ordering},
},
time::Duration,
};
use anyhow::{Context, Result, bail};
use bytes::Bytes;
use lanparty_ctrl::{
CONTROL_LENGTH_PREFIX_LEN, ControlMessage, EndpointHello, MAX_CONTROL_MESSAGE_LEN, RELAY_ALPN,
RoomCode, ServerWelcome, decode_control_frame, encode_control_message,
CONTROL_LENGTH_PREFIX_LEN, ControlMessage, DisconnectReason, EndpointHello,
MAX_CONTROL_MESSAGE_LEN, RELAY_ALPN, RoomCode, ServerWelcome, decode_control_frame,
encode_control_message,
};
use lanparty_obs::{QuicDiagnostics, TunnelStats};
use lanparty_proto::{EthernetFrame, FrameType, MacAddr, decode_datagram, encode_datagram};
@@ -28,6 +31,7 @@ use quinn::{ClientConfig, Endpoint, crypto::rustls::QuicClientConfig};
use rustls::pki_types::CertificateDer;
const MAX_CONTROL_FRAME_LEN: usize = CONTROL_LENGTH_PREFIX_LEN + MAX_CONTROL_MESSAGE_LEN;
const DISCONNECT_DRAIN_TIMEOUT: Duration = Duration::from_millis(250);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ClientIdentity {
@@ -288,6 +292,10 @@ impl ClientSession {
}
pub async fn shutdown(self, reason: &str) {
if send_disconnect(&self.connection, reason).await.is_ok() {
drain_disconnect().await;
}
self.connection.close(0_u32.into(), reason.as_bytes());
self.endpoint.wait_idle().await;
}
@@ -537,6 +545,25 @@ async fn send_control_event(connection: &quinn::Connection, message: ControlMess
Ok(())
}
async fn send_disconnect(connection: &quinn::Connection, message: &str) -> Result<()> {
send_control_event(
connection,
ControlMessage::Disconnect {
reason: DisconnectReason::Normal,
message: message.to_owned(),
},
)
.await
}
async fn drain_disconnect() {
let Some(runtime) = quinn::default_runtime() else {
return;
};
let mut timer = runtime.new_timer(runtime.now() + DISCONNECT_DRAIN_TIMEOUT);
poll_fn(|cx| timer.as_mut().poll(cx)).await;
}
#[cfg(test)]
mod tests {
use std::time::{Duration, SystemTime, UNIX_EPOCH};
@@ -711,6 +738,18 @@ mod tests {
assert_eq!(stats, TunnelStats::new(1, 1, 1, 1, 1, 1));
stats_received_tx.send(()).unwrap();
let mut disconnect_recv = connection.accept_uni().await.unwrap();
let disconnect_frame = disconnect_recv
.read_to_end(MAX_CONTROL_FRAME_LEN)
.await
.unwrap();
let disconnect_message = decode_control_frame(&disconnect_frame).unwrap();
let ControlMessage::Disconnect { reason, message } = disconnect_message else {
panic!("expected client disconnect event");
};
assert_eq!(reason, DisconnectReason::Normal);
assert_eq!(message, "test complete");
connection.closed().await;
endpoint.close(0_u32.into(), b"test complete");
endpoint.wait_idle().await;