feat(net): expose transport statistics

This commit is contained in:
Serkyo 2026-07-31 02:41:53 +02:00
parent 0c7785d869
commit 8b031de071
5 changed files with 178 additions and 1 deletions

View file

@ -2,12 +2,15 @@
//! Chunk-stream transport: the per-connection task that pumps chunk subscriptions and deliveries. //! Chunk-stream transport: the per-connection task that pumps chunk subscriptions and deliveries.
use std::sync::Arc;
use shared::protocol::chunk::{ChunkMessage, ChunkSubscribe}; use shared::protocol::chunk::{ChunkMessage, ChunkSubscribe};
use tokio::sync::mpsc::{Sender, UnboundedReceiver, UnboundedSender}; use tokio::sync::mpsc::{Sender, UnboundedReceiver, UnboundedSender};
use tracing::{debug, warn}; use tracing::{debug, warn};
use crate::codec::{MAX_CHUNK_FRAME_LEN, read_frame, write_frame}; use crate::codec::{MAX_CHUNK_FRAME_LEN, read_frame, write_frame};
use crate::runtime::ServerEvent; use crate::runtime::ServerEvent;
use crate::stats::NetCounters;
/// A synchronous handle the simulation loop uses to hand [`ChunkMessage`]s to a connection's chunk-stream task. /// A synchronous handle the simulation loop uses to hand [`ChunkMessage`]s to a connection's chunk-stream task.
/// ///
@ -123,6 +126,7 @@ pub(crate) async fn client_chunk_task(
connection: quinn::Connection, connection: quinn::Connection,
mut subscribe: UnboundedReceiver<ChunkSubscribe>, mut subscribe: UnboundedReceiver<ChunkSubscribe>,
deliveries: Sender<ChunkMessage>, deliveries: Sender<ChunkMessage>,
counters: &Arc<NetCounters>,
) { ) {
// The client opens the chunk stream after the handshake; the server accepts it, mirroring the control-stream convention. // The client opens the chunk stream after the handshake; the server accepts it, mirroring the control-stream convention.
let (mut send, mut recv) = match connection.open_bi().await { let (mut send, mut recv) = match connection.open_bi().await {
@ -138,6 +142,11 @@ pub(crate) async fn client_chunk_task(
loop { loop {
match read_frame::<ChunkMessage>(&mut recv, MAX_CHUNK_FRAME_LEN).await { match read_frame::<ChunkMessage>(&mut recv, MAX_CHUNK_FRAME_LEN).await {
Ok(message) => { Ok(message) => {
// Counted on arrival rather than on delivery to the UI, so the figure reflects what the transport received even while the UI thread is backpressuring below.
match message {
ChunkMessage::Chunk { .. } => counters.record_chunk(),
ChunkMessage::Drop { .. } => counters.record_drop(),
}
// `send` awaits when the delivery channel is full: the task suspends (yielding the runtime thread so the connection keeps ACKing) until the UI drains a slot, and until then reads no further frames, which backpressures the server via QUIC stream flow control. An error means the UI dropped its receiver, so the session ends. // `send` awaits when the delivery channel is full: the task suspends (yielding the runtime thread so the connection keeps ACKing) until the UI drains a slot, and until then reads no further frames, which backpressures the server via QUIC stream flow control. An error means the UI dropped its receiver, so the session ends.
if deliveries.send(message).await.is_err() { if deliveries.send(message).await.is_err() {
break; break;
@ -159,6 +168,7 @@ pub(crate) async fn client_chunk_task(
warn!(%error, "failed to write chunk subscribe; ending chunk stream"); warn!(%error, "failed to write chunk subscribe; ending chunk stream");
break; break;
} }
counters.record_subscribe();
} }
}; };

View file

@ -12,11 +12,13 @@ pub mod endpoint;
pub mod error; pub mod error;
pub mod handshake; pub mod handshake;
pub mod runtime; pub mod runtime;
pub mod stats;
pub use chunk::{ChunkSink, ChunkSubscriber}; pub use chunk::{ChunkSink, ChunkSubscriber};
pub use runtime::{ pub use runtime::{
ChunkStream, ClientLink, ConnectOutcome, NetworkServer, ServerEvent, connect_in_background, ChunkStream, ClientLink, ConnectOutcome, NetworkServer, ServerEvent, connect_in_background,
}; };
pub use stats::NetStats;
/// Default UDP port the server binds and the client connects to when none is configured. /// Default UDP port the server binds and the client connects to when none is configured.
// TODO: make the bind address and port configurable through server/client configuration. // TODO: make the bind address and port configurable through server/client configuration.

View file

@ -3,6 +3,7 @@
//! Threaded `tokio` runtime bridge between the async network and the synchronous simulation. //! Threaded `tokio` runtime bridge between the async network and the synchronous simulation.
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::{Arc, OnceLock};
use std::thread; use std::thread;
use shared::protocol::chunk::{ChunkMessage, ChunkSubscribe}; use shared::protocol::chunk::{ChunkMessage, ChunkSubscribe};
@ -13,6 +14,7 @@ use crate::chunk::{ChunkSink, ChunkSubscriber, chunk_stream_task, client_chunk_t
use crate::endpoint::{client_endpoint, server_endpoint}; use crate::endpoint::{client_endpoint, server_endpoint};
use crate::error::NetError; use crate::error::NetError;
use crate::handshake::{ServerConnection, accept_connection, connect}; use crate::handshake::{ServerConnection, accept_connection, connect};
use crate::stats::{self, NetCounters, NetStats};
/// Channel receiver delivering the outcome of a background client connect: the negotiated [`HandshakeAck`] on success, or a human-readable error string on failure. /// Channel receiver delivering the outcome of a background client connect: the negotiated [`HandshakeAck`] on success, or a human-readable error string on failure.
pub type ConnectOutcome = crossbeam_channel::Receiver<Result<HandshakeAck, String>>; pub type ConnectOutcome = crossbeam_channel::Receiver<Result<HandshakeAck, String>>;
@ -34,6 +36,20 @@ pub struct ClientLink {
pub subscribe: ChunkSubscriber, pub subscribe: ChunkSubscriber,
/// Receives chunk deliveries from the server, drained non-blocking each frame. /// Receives chunk deliveries from the server, drained non-blocking each frame.
pub chunks: ChunkStream, pub chunks: ChunkStream,
/// The live QUIC connection, published by the network thread once the handshake completes. Held privately so the `quinn` types stay inside this crate; the UI thread reads through [`ClientLink::stats`].
connection: Arc<OnceLock<quinn::Connection>>,
/// Application-level message counters shared with the chunk task.
counters: Arc<NetCounters>,
}
impl ClientLink {
/// Snapshots the connection's transport statistics.
///
/// Safe to call before the handshake completes; the result then reports the disconnected state rather than failing.
#[must_use]
pub fn stats(&self) -> NetStats {
stats::snapshot(self.connection.get(), &self.counters)
}
} }
/// An event surfaced by the network thread to the synchronous server loop. /// An event surfaced by the network thread to the synchronous server loop.
@ -257,6 +273,11 @@ pub fn connect_in_background(server_addr: SocketAddr, hello: ClientHello) -> Cli
let (outcome_tx, outcome_rx) = crossbeam_channel::bounded(1); let (outcome_tx, outcome_rx) = crossbeam_channel::bounded(1);
// Retained so a failure to spawn the thread can still be reported to the caller. // Retained so a failure to spawn the thread can still be reported to the caller.
let spawn_err_tx = outcome_tx.clone(); let spawn_err_tx = outcome_tx.clone();
// Published by the network thread once the handshake succeeds, so the UI thread can read `quinn`'s own connection statistics without owning the connection.
let connection = Arc::new(OnceLock::new());
let task_connection = Arc::clone(&connection);
let counters = Arc::new(NetCounters::default());
let task_counters = Arc::clone(&counters);
// Subscription updates flow UI -> network (sync send, async recv); chunk deliveries flow network -> UI (async send, sync try_recv). // Subscription updates flow UI -> network (sync send, async recv); chunk deliveries flow network -> UI (async send, sync try_recv).
let (subscribe_tx, subscribe_rx) = tokio::sync::mpsc::unbounded_channel::<ChunkSubscribe>(); let (subscribe_tx, subscribe_rx) = tokio::sync::mpsc::unbounded_channel::<ChunkSubscribe>();
// The delivery channel is bounded so a slow (e.g. debug-build) UI thread applies backpressure to the network task instead of letting undelivered chunks accumulate without limit. // The delivery channel is bounded so a slow (e.g. debug-build) UI thread applies backpressure to the network task instead of letting undelivered chunks accumulate without limit.
@ -292,8 +313,16 @@ pub fn connect_in_background(server_addr: SocketAddr, hello: ClientHello) -> Cli
if outcome_tx.send(Ok(connected.ack.clone())).is_err() { if outcome_tx.send(Ok(connected.ack.clone())).is_err() {
return; return;
} }
// The cell is written exactly once, here; a failure would mean a second handshake on one link, which cannot occur.
let _ = task_connection.set(connected.connection.clone());
// Pump the chunk stream on this thread until the UI drops its handles or the server closes the connection. // Pump the chunk stream on this thread until the UI drops its handles or the server closes the connection.
client_chunk_task(connected.connection, subscribe_rx, chunks_tx).await; client_chunk_task(
connected.connection,
subscribe_rx,
chunks_tx,
&task_counters,
)
.await;
warn!("server connection closed"); warn!("server connection closed");
} }
Err(error) => { Err(error) => {
@ -311,5 +340,7 @@ pub fn connect_in_background(server_addr: SocketAddr, hello: ClientHello) -> Cli
handshake: outcome_rx, handshake: outcome_rx,
subscribe: ChunkSubscriber::new(subscribe_tx), subscribe: ChunkSubscriber::new(subscribe_tx),
chunks: chunks_rx, chunks: chunks_rx,
connection,
counters,
} }
} }

103
crates/net/src/stats.rs Normal file
View file

@ -0,0 +1,103 @@
// SPDX-License-Identifier: AGPL-3.0-only
//! Transport statistics for a client connection.
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
/// Application-level message counters, incremented by the client's chunk task and read by the UI thread.
///
/// Held behind an [`Arc`] and mutated with relaxed atomics: each counter is independent, nothing else is ordered against them, and a reader that observes a slightly stale value is reporting a diagnostic figure, not making a decision.
#[derive(Debug, Default)]
pub(crate) struct NetCounters {
/// Chunk deliveries received from the server.
chunks_received: AtomicU64,
/// Chunk drop notifications received from the server.
drops_received: AtomicU64,
/// Subscription updates written to the server.
subscribes_sent: AtomicU64,
}
impl NetCounters {
/// Records one received chunk delivery.
pub(crate) fn record_chunk(&self) {
self.chunks_received.fetch_add(1, Ordering::Relaxed);
}
/// Records one received chunk drop notification.
pub(crate) fn record_drop(&self) {
self.drops_received.fetch_add(1, Ordering::Relaxed);
}
/// Records one subscription update written to the server.
pub(crate) fn record_subscribe(&self) {
self.subscribes_sent.fetch_add(1, Ordering::Relaxed);
}
}
/// A snapshot of one client connection's transport state.
///
/// Every field is a value copied at the moment of the call. A snapshot taken before the handshake completes reports `connected == false` and zeroes throughout, which is a meaningful state rather than missing data.
#[derive(Copy, Clone, Debug, Default, PartialEq)]
pub struct NetStats {
/// Whether a QUIC connection is currently established.
pub connected: bool,
/// Bytes carried in UDP datagrams sent on this connection, including QUIC framing and retransmissions.
pub bytes_sent: u64,
/// Bytes carried in UDP datagrams received on this connection, including QUIC framing.
pub bytes_received: u64,
/// UDP datagrams sent on this connection.
pub datagrams_sent: u64,
/// UDP datagrams received on this connection.
pub datagrams_received: u64,
/// Chunk deliveries received since the connection was established.
pub chunks_received: u64,
/// Chunk drop notifications received since the connection was established.
pub drops_received: u64,
/// Subscription updates written to the server since the connection was established.
pub subscribes_sent: u64,
/// The QUIC stack's current round-trip-time estimate, in milliseconds.
pub rtt_ms: f32,
/// Packets the congestion controller has declared lost on the current path.
pub lost_packets: u64,
/// Current congestion window, in bytes: the ceiling on data in flight.
pub congestion_window: u64,
/// Largest UDP payload the current path is known to carry, in bytes, as discovered by path MTU probing.
pub path_mtu: u16,
}
/// Reads a connection's transport statistics, or reports the disconnected state.
///
/// `connection` is [`None`] until the handshake completes and after the connection closes.
pub(crate) fn snapshot(
connection: Option<&quinn::Connection>,
counters: &Arc<NetCounters>,
) -> NetStats {
let mut stats = NetStats {
chunks_received: counters.chunks_received.load(Ordering::Relaxed),
drops_received: counters.drops_received.load(Ordering::Relaxed),
subscribes_sent: counters.subscribes_sent.load(Ordering::Relaxed),
..NetStats::default()
};
let Some(connection) = connection else {
return stats;
};
let quic = connection.stats();
// A connection handle outlives the connection itself; a close reason is how a torn-down connection distinguishes itself from a live one, and its final counters stay readable either way.
stats.connected = connection.close_reason().is_none();
stats.bytes_sent = quic.udp_tx.bytes;
stats.bytes_received = quic.udp_rx.bytes;
stats.datagrams_sent = quic.udp_tx.datagrams;
stats.datagrams_received = quic.udp_rx.datagrams;
stats.rtt_ms = quic.path.rtt.as_secs_f32() * 1000.0;
stats.lost_packets = quic.path.lost_packets;
stats.congestion_window = quic.path.cwnd;
stats.path_mtu = quic.path.current_mtu;
stats
}
#[cfg(test)]
#[path = "tests/stats.rs"]
mod tests;

View file

@ -0,0 +1,31 @@
// SPDX-License-Identifier: AGPL-3.0-only
//! Unit tests for the transport statistics snapshot.
use super::*;
#[test]
fn snapshot_without_a_connection_reports_the_disconnected_state() {
let counters = Arc::new(NetCounters::default());
let stats = snapshot(None, &counters);
assert!(!stats.connected);
assert_eq!(stats, NetStats::default());
}
#[test]
fn counters_are_reported_before_a_connection_exists() {
let counters = Arc::new(NetCounters::default());
counters.record_chunk();
counters.record_chunk();
counters.record_drop();
counters.record_subscribe();
let stats = snapshot(None, &counters);
assert_eq!(stats.chunks_received, 2);
assert_eq!(stats.drops_received, 1);
assert_eq!(stats.subscribes_sent, 1);
// Transport figures stay zero: they come from the QUIC stack, which has nothing to report yet.
assert_eq!(stats.bytes_sent, 0);
assert_eq!(stats.bytes_received, 0);
}