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
- welcome/reject handling with assigned peer id and effective TAP MTU
- 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
- Ethernet frame send/receive helpers over QUIC DATAGRAM with budget checks and
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
reinstalled before it reloads the configured `NetworkAddress`.
It prints and reports client diagnostics snapshots with relay reachability,
LAN-gateway presence, route-pinning, QUIC datagram budget, TAP status/IP,
broadcast frame flow, frame/datagram counters, and drops. The periodic
diagnostics refresh the TAP unicast IP so DHCP results that arrive after
bridging starts become visible in later status lines. Each snapshot also emits
short user-facing lines such as relay/gateway connection status, relay-route
and TAP readiness warnings, DHCP address presence, and broadcast-flow
confirmation when those signals are observed. Malformed TAP frames, jumbo
frames, and TAP frames whose encoded datagrams exceed the negotiated QUIC budget
are counted and dropped before relay send without stopping the bridge.
LAN-gateway presence, route-pinning, QUIC datagram budget, relay RTT, TAP
status/IP, broadcast frame flow, frame/datagram counters, and drops. The
periodic diagnostics refresh the TAP unicast IP so DHCP results that arrive
after bridging starts become visible in later status lines. Each snapshot also
emits short user-facing lines such as relay/gateway connection status,
relay-route and TAP readiness warnings, DHCP address presence, relay RTT, and
broadcast-flow confirmation when those signals are observed. Malformed TAP
frames, jumbo frames, and TAP frames whose encoded datagrams exceed the
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
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
+13 -1
View File
@@ -265,8 +265,9 @@ impl ClientSession {
}
#[must_use]
pub const fn quic_diagnostics(&self) -> QuicDiagnostics {
pub fn quic_diagnostics(&self) -> QuicDiagnostics {
QuicDiagnostics::new(true, Some(self.quic_max_datagram_size))
.with_relay_rtt_ms(Some(duration_millis_saturating(self.connection.rtt())))
}
#[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(
connection: &quinn::Connection,
message: ControlMessage,
@@ -844,6 +849,7 @@ mod tests {
client.quic_diagnostics().max_datagram_size(),
Some(client.quic_max_datagram_size())
);
assert!(client.quic_diagnostics().relay_rtt_ms().is_some());
let relay_io = client.relay_io();
assert_eq!(relay_io.welcome().peer_id(), 2);
assert_eq!(
@@ -933,6 +939,12 @@ mod tests {
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>) {
let certified_key =
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 {
let stats = diagnostics.stats();
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().gateway_connected()),
yes_no(diagnostics.relay().route_pinned()),
yes_no(diagnostics.quic().datagram_supported()),
optional_label(diagnostics.quic().max_datagram_size()),
optional_millis_label(diagnostics.quic().relay_rtt_ms()),
yes_no(diagnostics.tap().adapter_found()),
optional_label(diagnostics.tap().mac()),
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())
}
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 {
match diagnostic.level() {
UserDiagnosticLevel::Info => diagnostic.message().to_owned(),
@@ -875,7 +880,7 @@ mod tests {
fn formats_client_diagnostics_status_line() {
let diagnostics = ClientDiagnostics::new(
RelayDiagnostics::new(true, true, true),
QuicDiagnostics::new(true, Some(1400)),
QuicDiagnostics::new(true, Some(1400)).with_relay_rtt_ms(Some(23)),
TapDiagnostics::new(
true,
Some(mac(1)),
@@ -887,7 +892,7 @@ mod tests {
assert_eq!(
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!(
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 {
datagram_supported: bool,
max_datagram_size: Option<u16>,
#[serde(default)]
relay_rtt_ms: Option<u64>,
}
impl QuicDiagnostics {
@@ -266,9 +268,16 @@ impl QuicDiagnostics {
Self {
datagram_supported,
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]
pub const fn datagram_supported(&self) -> bool {
self.datagram_supported
@@ -278,6 +287,11 @@ impl QuicDiagnostics {
pub const fn max_datagram_size(&self) -> Option<u16> {
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)]
@@ -408,6 +422,13 @@ impl ClientDiagnostics {
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 {
diagnostics.push(UserDiagnostic::new(
UserDiagnosticLevel::Info,
@@ -537,6 +558,7 @@ mod tests {
assert!(diagnostics.relay().gateway_connected());
assert!(diagnostics.quic().datagram_supported());
assert_eq!(diagnostics.quic().max_datagram_size(), Some(1400));
assert_eq!(diagnostics.quic().relay_rtt_ms(), None);
assert!(diagnostics.tap().adapter_found());
assert_eq!(diagnostics.tap().mac(), Some(mac));
assert_eq!(diagnostics.tap().mtu(), Some(1200));
@@ -564,11 +586,24 @@ mod tests {
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]
fn derives_user_diagnostics_from_client_snapshot() {
let diagnostics = ClientDiagnostics::new(
RelayDiagnostics::new(true, true, true),
QuicDiagnostics::new(true, Some(1400)),
QuicDiagnostics::new(true, Some(1400)).with_relay_rtt_ms(Some(23)),
TapDiagnostics::new(
true,
Some(MacAddr::new([0x02, 1, 2, 3, 4, 5])),
@@ -590,6 +625,7 @@ mod tests {
"Connected to relay",
"Connected to LAN gateway",
"DHCP received: 10.73.42.51",
"Relay RTT: 23 ms",
"Broadcast traffic flowing",
]
);