Files
noq/perf/src/client.rs
T
Philipp Krüger 269e5e0c38 feat: Add Endpoint::wait_all_draining to enable faster endpoint closing (#651)
## Description

Adds an API for waiting for the start of the draining period in
`Endpoint::wait_all_draining` instead of waiting for draining to have
finished with `Endpoint::wait_idle`.

This allows dropping the `Endpoint` once all connections became inactive
and there is no need to wait for all connections to have drained.

This is all motivated by [this
paragraph](https://datatracker.ietf.org/doc/html/rfc9000#section-10.2-6)
in the QUIC spec:

> Endpoints that have some alternative means to ensure that
late-arriving packets do not induce a response, such as those that are
able to close the UDP socket, MAY end these states earlier to allow for
faster resource recovery. Servers that retain an open socket for
accepting new connections SHOULD NOT end the closing or draining state
early.

And finally, we're not replacing `Endpoint::wait_idle` and instead keep
it around as some tests require waiting for all `Connection`s to be
dropped before proceeding, which is only guaranteed by `wait_idle` and
not `wait_all_draining`.

## Breaking Changes

- Only an addition: `Endpoint::wait_all_draining` was added.

## Change checklist
<!-- Remove any that are not relevant. -->
- [x] Self-review.
- [x] Documentation updates following the [style
guide](https://rust-lang.github.io/rfcs/1574-more-api-documentation-conventions.html#appendix-a-full-conventions-text),
if relevant.
- [x] Tests if relevant.
- [x] All breaking changes documented.
2026-05-21 09:42:20 +00:00

427 lines
13 KiB
Rust

#[cfg(feature = "json-output")]
use std::path::{Path, PathBuf};
use std::{
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
sync::Arc,
time::{Duration, Instant},
};
use anyhow::{Context, Result};
use bytes::Bytes;
use clap::Parser;
use noq::{TokioRuntime, crypto::rustls::QuicClientConfig};
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use tokio::sync::Semaphore;
use tracing::{debug, error, info};
use crate::{
CommonOpt, PERF_CIPHER_SUITES,
noprotection::NoProtectionClientConfig,
parse_byte_size,
stats::{OpenStreamStats, Stats},
};
/// Connects to a QUIC perf server and maintains a specified pattern of requests until interrupted
#[derive(Parser)]
#[clap(name = "client")]
pub struct Opt {
/// Host to connect to
#[clap(default_value = "localhost:4433")]
host: String,
/// Override DNS resolution for host
#[clap(long)]
ip: Option<IpAddr>,
/// Specify the local socket address
#[clap(long)]
local_addr: Option<SocketAddr>,
/// Number of unidirectional requests to maintain concurrently
#[clap(long, default_value = "0")]
uni_requests: u64,
/// Number of bidirectional requests to maintain concurrently
#[clap(long, default_value = "1")]
bi_requests: u64,
/// Number of bytes to request
///
/// This can use SI suffixes for sizes. For example, 1M will transfer
/// 1MiB, 10G will transfer 10GiB.
#[clap(long, default_value = "1M", value_parser = parse_byte_size)]
download_size: u64,
/// Number of bytes to transmit, in addition to the request header
///
/// This can use SI suffixes for sizes. For example, 1M will transfer
/// 1MiB, 10G will transfer 10GiB.
#[clap(long, default_value = "1M", value_parser = parse_byte_size)]
upload_size: u64,
/// The time to run in seconds
#[clap(long, default_value = "60")]
duration: u64,
/// The interval in seconds at which stats are reported
#[clap(long, default_value = "1")]
interval: u64,
/// File path to output JSON statistics to. If the file is '-', stdout will be used
#[cfg(feature = "json-output")]
#[clap(long)]
json: Option<PathBuf>,
/// Common options
#[command(flatten)]
common: CommonOpt,
}
pub async fn run(opt: Opt) -> Result<()> {
let mut host_parts = opt.host.split(':');
let host_name = host_parts.next().unwrap();
let host_port = host_parts
.next()
.map_or(Ok(443), |x| x.parse())
.context("parsing port")?;
let addr = match opt.ip {
None => tokio::net::lookup_host(&opt.host)
.await
.context("resolving host")?
.next()
.unwrap(),
Some(ip) => SocketAddr::new(ip, host_port),
};
info!("connecting to {} at {}", host_name, addr);
let bind_addr = opt.local_addr.unwrap_or_else(|| {
let unspec = if addr.is_ipv4() {
Ipv4Addr::UNSPECIFIED.into()
} else {
Ipv6Addr::UNSPECIFIED.into()
};
SocketAddr::new(unspec, 0)
});
info!("local addr {:?}", bind_addr);
let socket = opt.common.bind_socket(bind_addr)?;
let mut endpoint_cfg = noq::EndpointConfig::default();
endpoint_cfg.max_udp_payload_size(opt.common.max_udp_payload_size)?;
let endpoint = noq::Endpoint::new(endpoint_cfg, None, socket, Arc::new(TokioRuntime))?;
let default_provider = rustls::crypto::ring::default_provider();
let provider = Arc::new(rustls::crypto::CryptoProvider {
cipher_suites: PERF_CIPHER_SUITES.into(),
..default_provider
});
let mut crypto = rustls::ClientConfig::builder_with_provider(provider.clone())
.with_protocol_versions(&[&rustls::version::TLS13])
.unwrap()
.dangerous()
.with_custom_certificate_verifier(SkipServerVerification::new(provider))
.with_no_client_auth();
crypto.alpn_protocols = vec![b"perf".to_vec()];
if opt.common.keylog {
crypto.key_log = Arc::new(rustls::KeyLogFile::new());
}
let transport = opt.common.build_transport_config(
#[cfg(feature = "qlog")]
"perf-client",
)?;
let crypto = QuicClientConfig::try_from(crypto)?;
let mut config = noq::ClientConfig::new(match opt.common.no_protection {
true => Arc::new(NoProtectionClientConfig::new(crypto)),
false => Arc::new(crypto),
});
config.transport_config(Arc::new(transport));
let stream_stats = OpenStreamStats::default();
let connection = endpoint
.connect_with(config, addr, host_name)?
.await
.context("connecting")?;
info!("established");
let drive_fut = async {
tokio::try_join!(
drive_uni(
connection.clone(),
stream_stats.clone(),
opt.uni_requests,
opt.upload_size,
opt.download_size
),
drive_bi(
connection.clone(),
stream_stats.clone(),
opt.bi_requests,
opt.upload_size,
opt.download_size
)
)
};
let mut stats = Stats::default();
let stats_fut = async {
let interval_duration = Duration::from_secs(opt.interval);
#[cfg(feature = "json-output")]
let allow_table_output = opt.json.clone().is_none_or(|path| path != Path::new("-"));
#[cfg(not(feature = "json-output"))]
let allow_table_output = true;
loop {
let start = Instant::now();
tokio::time::sleep(interval_duration).await;
{
stats.on_interval(start, &stream_stats);
if allow_table_output {
stats.print();
if opt.common.conn_stats {
println!("{:?}\n", connection.stats());
}
}
}
}
};
tokio::select! {
_ = drive_fut => {}
_ = stats_fut => {}
_ = tokio::signal::ctrl_c() => {
info!("shutting down");
connection.close(0u32.into(), b"interrupted");
}
// Add a small duration so the final interval can be reported
_ = tokio::time::sleep(Duration::from_secs(opt.duration) + Duration::from_millis(200)) => {
info!("shutting down");
connection.close(0u32.into(), b"done");
}
}
endpoint.wait_all_draining().await;
#[cfg(feature = "json-output")]
if let Some(path) = opt.json {
stats.print_json(path.as_path())?;
}
Ok(())
}
async fn drain_stream(
mut stream: noq::RecvStream,
download: u64,
stream_stats: OpenStreamStats,
) -> Result<()> {
if download == 0 {
return Ok(());
}
#[rustfmt::skip]
let mut bufs = [
Bytes::new(), Bytes::new(), Bytes::new(), Bytes::new(),
Bytes::new(), Bytes::new(), Bytes::new(), Bytes::new(),
Bytes::new(), Bytes::new(), Bytes::new(), Bytes::new(),
Bytes::new(), Bytes::new(), Bytes::new(), Bytes::new(),
Bytes::new(), Bytes::new(), Bytes::new(), Bytes::new(),
Bytes::new(), Bytes::new(), Bytes::new(), Bytes::new(),
Bytes::new(), Bytes::new(), Bytes::new(), Bytes::new(),
Bytes::new(), Bytes::new(), Bytes::new(), Bytes::new(),
];
let download_start = Instant::now();
let recv_stream_stats = stream_stats.new_receiver(&stream, download);
let mut first_byte = true;
while let Some(size) = stream.read_many_chunks(&mut bufs[..]).await? {
if first_byte {
recv_stream_stats.on_first_byte(download_start.elapsed());
first_byte = false;
}
let bytes_received = bufs[..size].iter().map(|b| b.len()).sum();
recv_stream_stats.on_bytes(bytes_received);
}
if first_byte {
recv_stream_stats.on_first_byte(download_start.elapsed());
}
recv_stream_stats.finish(download_start.elapsed());
debug!("response finished on {}", stream.id());
Ok(())
}
async fn drive_uni(
connection: noq::Connection,
stream_stats: OpenStreamStats,
concurrency: u64,
upload: u64,
download: u64,
) -> Result<()> {
if concurrency == 0 {
return Ok(());
}
let sem = Arc::new(Semaphore::new(concurrency as usize));
loop {
let permit = sem.clone().acquire_owned().await.unwrap();
let send = connection.open_uni().await?;
let stream_stats = stream_stats.clone();
debug!("sending request on {}", send.id());
let connection = connection.clone();
tokio::spawn(async move {
if let Err(e) = request_uni(send, connection, upload, download, stream_stats).await {
error!("sending request failed: {:#}", e);
}
drop(permit);
});
}
}
async fn request_uni(
send: noq::SendStream,
conn: noq::Connection,
upload: u64,
download: u64,
stream_stats: OpenStreamStats,
) -> Result<()> {
request(send, upload, download, stream_stats.clone()).await?;
let recv = conn.accept_uni().await?;
drain_stream(recv, download, stream_stats).await?;
Ok(())
}
async fn request(
mut send: noq::SendStream,
mut upload: u64,
download: u64,
stream_stats: OpenStreamStats,
) -> Result<()> {
let upload_start = Instant::now();
send.write_all(&download.to_be_bytes()).await?;
if upload == 0 {
send.finish().unwrap();
return Ok(());
}
let send_stream_stats = stream_stats.new_sender(&send, upload);
static DATA: [u8; 1024 * 1024] = [42; 1024 * 1024];
while upload > 0 {
let chunk_len = upload.min(DATA.len() as u64);
send.write_chunk(Bytes::from_static(&DATA[..chunk_len as usize]))
.await
.context("sending response")?;
send_stream_stats.on_bytes(chunk_len as usize);
upload -= chunk_len;
}
send.finish().unwrap();
// Wait for stream to close
_ = send.stopped().await;
send_stream_stats.finish(upload_start.elapsed());
debug!("upload finished on {}", send.id());
Ok(())
}
async fn drive_bi(
connection: noq::Connection,
stream_stats: OpenStreamStats,
concurrency: u64,
upload: u64,
download: u64,
) -> Result<()> {
if concurrency == 0 {
return Ok(());
}
let sem = Arc::new(Semaphore::new(concurrency as usize));
loop {
let permit = sem.clone().acquire_owned().await.unwrap();
let (send, recv) = connection.open_bi().await?;
let stream_stats = stream_stats.clone();
debug!("sending request on {}", send.id());
tokio::spawn(async move {
if let Err(e) = request_bi(send, recv, upload, download, stream_stats).await {
error!("request failed: {:#}", e);
}
drop(permit);
});
}
}
async fn request_bi(
send: noq::SendStream,
recv: noq::RecvStream,
upload: u64,
download: u64,
stream_stats: OpenStreamStats,
) -> Result<()> {
request(send, upload, download, stream_stats.clone()).await?;
drain_stream(recv, download, stream_stats).await?;
Ok(())
}
#[derive(Debug)]
struct SkipServerVerification(Arc<rustls::crypto::CryptoProvider>);
impl SkipServerVerification {
fn new(provider: Arc<rustls::crypto::CryptoProvider>) -> Arc<Self> {
Arc::new(Self(provider))
}
}
impl rustls::client::danger::ServerCertVerifier for SkipServerVerification {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp: &[u8],
_now: UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls12_signature(
message,
cert,
dss,
&self.0.signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls13_signature(
message,
cert,
dss,
&self.0.signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
self.0.signature_verification_algorithms.supported_schemes()
}
}