feat(client): report relay RTT diagnostics

Expose the active QUIC connection RTT in shared client diagnostics and in the
Windows client status line. This gives operators a live relay-path latency
signal without pretending to measure end-to-end gateway or LAN latency.

The new diagnostics field defaults to unknown when older JSON snapshots omit it,
so consumers can read pre-change snapshots without special migration code. The
user-facing diagnostics now print Relay RTT only when the client has an active
QUIC measurement.

Test Plan:
- cargo fmt --check
- git diff --check
- cargo test --workspace
- cargo clippy --workspace --all-targets -- -D warnings

Refs: PLAN.md logging and diagnostics section
This commit is contained in:
2026-05-21 23:00:18 +02:00
parent 77025e6564
commit e69d41691a
4 changed files with 70 additions and 15 deletions
+11 -9
View File
@@ -59,6 +59,7 @@ Platform-neutral remote client relay session:
- client hello with room, virtual MAC, and datagram budget - client hello with room, virtual MAC, and datagram budget
- welcome/reject handling with assigned peer id and effective TAP MTU - welcome/reject handling with assigned peer id and effective TAP MTU
- QUIC DATAGRAM support and negotiated datagram budget diagnostics - QUIC DATAGRAM support and negotiated datagram budget diagnostics
- relay RTT diagnostics from the active QUIC connection
- reliable relay control-event reads for peer lifecycle messages - reliable relay control-event reads for peer lifecycle messages
- Ethernet frame send/receive helpers over QUIC DATAGRAM with budget checks and - Ethernet frame send/receive helpers over QUIC DATAGRAM with budget checks and
local drop outcomes for malformed or oversized sends local drop outcomes for malformed or oversized sends
@@ -222,15 +223,16 @@ if the driver-reported MAC does not match the tunnel identity, because an
already-initialized Windows TAP adapter may need to be disabled/enabled or already-initialized Windows TAP adapter may need to be disabled/enabled or
reinstalled before it reloads the configured `NetworkAddress`. reinstalled before it reloads the configured `NetworkAddress`.
It prints and reports client diagnostics snapshots with relay reachability, It prints and reports client diagnostics snapshots with relay reachability,
LAN-gateway presence, route-pinning, QUIC datagram budget, TAP status/IP, LAN-gateway presence, route-pinning, QUIC datagram budget, relay RTT, TAP
broadcast frame flow, frame/datagram counters, and drops. The periodic status/IP, broadcast frame flow, frame/datagram counters, and drops. The
diagnostics refresh the TAP unicast IP so DHCP results that arrive after periodic diagnostics refresh the TAP unicast IP so DHCP results that arrive
bridging starts become visible in later status lines. Each snapshot also emits after bridging starts become visible in later status lines. Each snapshot also
short user-facing lines such as relay/gateway connection status, relay-route emits short user-facing lines such as relay/gateway connection status,
and TAP readiness warnings, DHCP address presence, and broadcast-flow relay-route and TAP readiness warnings, DHCP address presence, relay RTT, and
confirmation when those signals are observed. Malformed TAP frames, jumbo broadcast-flow confirmation when those signals are observed. Malformed TAP
frames, and TAP frames whose encoded datagrams exceed the negotiated QUIC budget frames, jumbo frames, and TAP frames whose encoded datagrams exceed the
are counted and dropped before relay send without stopping the bridge. negotiated QUIC budget are counted and dropped before relay send without
stopping the bridge.
Relay lifecycle events are logged as they arrive, including gateway joins and Relay lifecycle events are logged as they arrive, including gateway joins and
peer leaves. The client remembers peer identities from join and catch-up events peer leaves. The client remembers peer identities from join and catch-up events
so later leave logs can identify a disconnected LAN gateway or client MAC when so later leave logs can identify a disconnected LAN gateway or client MAC when
+13 -1
View File
@@ -265,8 +265,9 @@ impl ClientSession {
} }
#[must_use] #[must_use]
pub const fn quic_diagnostics(&self) -> QuicDiagnostics { pub fn quic_diagnostics(&self) -> QuicDiagnostics {
QuicDiagnostics::new(true, Some(self.quic_max_datagram_size)) QuicDiagnostics::new(true, Some(self.quic_max_datagram_size))
.with_relay_rtt_ms(Some(duration_millis_saturating(self.connection.rtt())))
} }
#[must_use] #[must_use]
@@ -568,6 +569,10 @@ fn client_bind_addr(relay_addr: SocketAddr) -> SocketAddr {
} }
} }
fn duration_millis_saturating(duration: Duration) -> u64 {
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
}
async fn request_control_message( async fn request_control_message(
connection: &quinn::Connection, connection: &quinn::Connection,
message: ControlMessage, message: ControlMessage,
@@ -844,6 +849,7 @@ mod tests {
client.quic_diagnostics().max_datagram_size(), client.quic_diagnostics().max_datagram_size(),
Some(client.quic_max_datagram_size()) Some(client.quic_max_datagram_size())
); );
assert!(client.quic_diagnostics().relay_rtt_ms().is_some());
let relay_io = client.relay_io(); let relay_io = client.relay_io();
assert_eq!(relay_io.welcome().peer_id(), 2); assert_eq!(relay_io.welcome().peer_id(), 2);
assert_eq!( assert_eq!(
@@ -933,6 +939,12 @@ mod tests {
assert_eq!(snapshot.malformed_frames(), 1); assert_eq!(snapshot.malformed_frames(), 1);
} }
#[test]
fn converts_relay_rtt_to_milliseconds() {
assert_eq!(duration_millis_saturating(Duration::from_micros(999)), 0);
assert_eq!(duration_millis_saturating(Duration::from_millis(23)), 23);
}
fn test_server_config() -> (ServerConfig, CertificateDer<'static>) { fn test_server_config() -> (ServerConfig, CertificateDer<'static>) {
let certified_key = let certified_key =
rcgen::generate_simple_self_signed(vec!["lanparty-relay.local".into()]).unwrap(); rcgen::generate_simple_self_signed(vec!["lanparty-relay.local".into()]).unwrap();
+9 -4
View File
@@ -458,12 +458,13 @@ async fn print_and_report_client_diagnostics(
fn format_client_diagnostics(diagnostics: &ClientDiagnostics) -> String { fn format_client_diagnostics(diagnostics: &ClientDiagnostics) -> String {
let stats = diagnostics.stats(); let stats = diagnostics.stats();
format!( format!(
"client diagnostics: relay reachable {} gateway connected {} route pinned {}; QUIC datagrams {} max {}; TAP found {} MAC {} MTU {} IP {}; frames tx {} rx {} broadcast tx {} rx {} datagrams tx {} rx {} drops {} malformed {}", "client diagnostics: relay reachable {} gateway connected {} route pinned {}; QUIC datagrams {} max {} relay RTT {}; TAP found {} MAC {} MTU {} IP {}; frames tx {} rx {} broadcast tx {} rx {} datagrams tx {} rx {} drops {} malformed {}",
yes_no(diagnostics.relay().reachable()), yes_no(diagnostics.relay().reachable()),
yes_no(diagnostics.relay().gateway_connected()), yes_no(diagnostics.relay().gateway_connected()),
yes_no(diagnostics.relay().route_pinned()), yes_no(diagnostics.relay().route_pinned()),
yes_no(diagnostics.quic().datagram_supported()), yes_no(diagnostics.quic().datagram_supported()),
optional_label(diagnostics.quic().max_datagram_size()), optional_label(diagnostics.quic().max_datagram_size()),
optional_millis_label(diagnostics.quic().relay_rtt_ms()),
yes_no(diagnostics.tap().adapter_found()), yes_no(diagnostics.tap().adapter_found()),
optional_label(diagnostics.tap().mac()), optional_label(diagnostics.tap().mac()),
optional_label(diagnostics.tap().mtu()), optional_label(diagnostics.tap().mtu()),
@@ -487,6 +488,10 @@ fn optional_label<T: std::fmt::Display>(value: Option<T>) -> String {
value.map_or_else(|| "unknown".to_string(), |value| value.to_string()) value.map_or_else(|| "unknown".to_string(), |value| value.to_string())
} }
fn optional_millis_label(value: Option<u64>) -> String {
value.map_or_else(|| "unknown".to_string(), |value| format!("{value} ms"))
}
fn format_user_diagnostic(diagnostic: &UserDiagnostic) -> String { fn format_user_diagnostic(diagnostic: &UserDiagnostic) -> String {
match diagnostic.level() { match diagnostic.level() {
UserDiagnosticLevel::Info => diagnostic.message().to_owned(), UserDiagnosticLevel::Info => diagnostic.message().to_owned(),
@@ -875,7 +880,7 @@ mod tests {
fn formats_client_diagnostics_status_line() { fn formats_client_diagnostics_status_line() {
let diagnostics = ClientDiagnostics::new( let diagnostics = ClientDiagnostics::new(
RelayDiagnostics::new(true, true, true), RelayDiagnostics::new(true, true, true),
QuicDiagnostics::new(true, Some(1400)), QuicDiagnostics::new(true, Some(1400)).with_relay_rtt_ms(Some(23)),
TapDiagnostics::new( TapDiagnostics::new(
true, true,
Some(mac(1)), Some(mac(1)),
@@ -887,7 +892,7 @@ mod tests {
assert_eq!( assert_eq!(
format_client_diagnostics(&diagnostics), format_client_diagnostics(&diagnostics),
"client diagnostics: relay reachable yes gateway connected yes route pinned yes; QUIC datagrams yes max 1400; TAP found yes MAC 02:00:00:00:00:01 MTU 1200 IP 10.73.42.51; frames tx 1 rx 2 broadcast tx 7 rx 8 datagrams tx 3 rx 4 drops 5 malformed 6" "client diagnostics: relay reachable yes gateway connected yes route pinned yes; QUIC datagrams yes max 1400 relay RTT 23 ms; TAP found yes MAC 02:00:00:00:00:01 MTU 1200 IP 10.73.42.51; frames tx 1 rx 2 broadcast tx 7 rx 8 datagrams tx 3 rx 4 drops 5 malformed 6"
); );
} }
@@ -902,7 +907,7 @@ mod tests {
assert_eq!( assert_eq!(
format_client_diagnostics(&diagnostics), format_client_diagnostics(&diagnostics),
"client diagnostics: relay reachable yes gateway connected no route pinned no; QUIC datagrams no max unknown; TAP found no MAC unknown MTU unknown IP unknown; frames tx 0 rx 0 broadcast tx 0 rx 0 datagrams tx 0 rx 0 drops 0 malformed 0" "client diagnostics: relay reachable yes gateway connected no route pinned no; QUIC datagrams no max unknown relay RTT unknown; TAP found no MAC unknown MTU unknown IP unknown; frames tx 0 rx 0 broadcast tx 0 rx 0 datagrams tx 0 rx 0 drops 0 malformed 0"
); );
} }
+37 -1
View File
@@ -258,6 +258,8 @@ impl RelayDiagnostics {
pub struct QuicDiagnostics { pub struct QuicDiagnostics {
datagram_supported: bool, datagram_supported: bool,
max_datagram_size: Option<u16>, max_datagram_size: Option<u16>,
#[serde(default)]
relay_rtt_ms: Option<u64>,
} }
impl QuicDiagnostics { impl QuicDiagnostics {
@@ -266,9 +268,16 @@ impl QuicDiagnostics {
Self { Self {
datagram_supported, datagram_supported,
max_datagram_size, max_datagram_size,
relay_rtt_ms: None,
} }
} }
#[must_use]
pub const fn with_relay_rtt_ms(mut self, relay_rtt_ms: Option<u64>) -> Self {
self.relay_rtt_ms = relay_rtt_ms;
self
}
#[must_use] #[must_use]
pub const fn datagram_supported(&self) -> bool { pub const fn datagram_supported(&self) -> bool {
self.datagram_supported self.datagram_supported
@@ -278,6 +287,11 @@ impl QuicDiagnostics {
pub const fn max_datagram_size(&self) -> Option<u16> { pub const fn max_datagram_size(&self) -> Option<u16> {
self.max_datagram_size self.max_datagram_size
} }
#[must_use]
pub const fn relay_rtt_ms(&self) -> Option<u64> {
self.relay_rtt_ms
}
} }
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)] #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
@@ -408,6 +422,13 @@ impl ClientDiagnostics {
diagnostics.push(UserDiagnostic::new(UserDiagnosticLevel::Info, message)); diagnostics.push(UserDiagnostic::new(UserDiagnosticLevel::Info, message));
} }
if let Some(rtt_ms) = self.quic.relay_rtt_ms() {
diagnostics.push(UserDiagnostic::new(
UserDiagnosticLevel::Info,
format!("Relay RTT: {rtt_ms} ms"),
));
}
if self.stats.broadcast_frames_tx() > 0 || self.stats.broadcast_frames_rx() > 0 { if self.stats.broadcast_frames_tx() > 0 || self.stats.broadcast_frames_rx() > 0 {
diagnostics.push(UserDiagnostic::new( diagnostics.push(UserDiagnostic::new(
UserDiagnosticLevel::Info, UserDiagnosticLevel::Info,
@@ -537,6 +558,7 @@ mod tests {
assert!(diagnostics.relay().gateway_connected()); assert!(diagnostics.relay().gateway_connected());
assert!(diagnostics.quic().datagram_supported()); assert!(diagnostics.quic().datagram_supported());
assert_eq!(diagnostics.quic().max_datagram_size(), Some(1400)); assert_eq!(diagnostics.quic().max_datagram_size(), Some(1400));
assert_eq!(diagnostics.quic().relay_rtt_ms(), None);
assert!(diagnostics.tap().adapter_found()); assert!(diagnostics.tap().adapter_found());
assert_eq!(diagnostics.tap().mac(), Some(mac)); assert_eq!(diagnostics.tap().mac(), Some(mac));
assert_eq!(diagnostics.tap().mtu(), Some(1200)); assert_eq!(diagnostics.tap().mtu(), Some(1200));
@@ -564,11 +586,24 @@ mod tests {
assert_eq!(stats.broadcast_frames_rx(), 0); assert_eq!(stats.broadcast_frames_rx(), 0);
} }
#[test]
fn defaults_missing_relay_rtt_to_unknown() {
let diagnostics: QuicDiagnostics = serde_json::from_str(
r#"{
"datagram_supported": true,
"max_datagram_size": 1400
}"#,
)
.unwrap();
assert_eq!(diagnostics.relay_rtt_ms(), None);
}
#[test] #[test]
fn derives_user_diagnostics_from_client_snapshot() { fn derives_user_diagnostics_from_client_snapshot() {
let diagnostics = ClientDiagnostics::new( let diagnostics = ClientDiagnostics::new(
RelayDiagnostics::new(true, true, true), RelayDiagnostics::new(true, true, true),
QuicDiagnostics::new(true, Some(1400)), QuicDiagnostics::new(true, Some(1400)).with_relay_rtt_ms(Some(23)),
TapDiagnostics::new( TapDiagnostics::new(
true, true,
Some(MacAddr::new([0x02, 1, 2, 3, 4, 5])), Some(MacAddr::new([0x02, 1, 2, 3, 4, 5])),
@@ -590,6 +625,7 @@ mod tests {
"Connected to relay", "Connected to relay",
"Connected to LAN gateway", "Connected to LAN gateway",
"DHCP received: 10.73.42.51", "DHCP received: 10.73.42.51",
"Relay RTT: 23 ms",
"Broadcast traffic flowing", "Broadcast traffic flowing",
] ]
); );