irtt-client and irtt-server mutually dev-depended on each other for cross-crate integration tests. That cycle blocks `cargo publish` for either crate whenever the other doesn't already exist on the registry at the required version, which is exactly the situation for a crate's first-ever release (irtt-server has never been published before). Introduce a new `irtt-integration-tests` crate (publish = false, no library target) to own tests whose actual subject is the interaction between two or more irtt-* production crates: crate-local behavior -> crate's own tests/ cross-crate/system behavior -> irtt-integration-tests Move every test that imports both irtt_client and irtt_server there: the client-side support harness (hmac, large_packets, protocol_options, fill_negotiation) and the server-side real-socket tests that drive a real AsyncClient against a real Server (async_client, negotiation_policy, fill_wire_bytes, traffic_class, wildcard_source). server_set.rs stays in irtt-server/tests since it only uses irtt_proto as test machinery to drive irtt-server directly, which is a normal one-directional test dependency, not a peer relationship. irtt-server's dev-dependency on irtt-client is dropped entirely now that nothing under crates/irtt-server/tests needs it, which fully breaks the cycle from that side. irtt-client keeps a one-directional dev-dependency on irtt-server: a handful of white-box unit tests in src/async_client/tests.rs assert on private AsyncClient internals (socket, remote, test_hooks) while also needing a real in-tree server for blocking/async equivalence checks, so they can't move to an external crate without losing that access. Their InTreeServer helper is duplicated locally rather than shared, matching the existing precedent of irtt-cli's own tests/support/in_tree_server.rs.
226 lines
8.2 KiB
Rust
226 lines
8.2 KiB
Rust
//! What the server's replies actually carry on the wire.
|
|
//!
|
|
//! Everything else about reply marking is decided deterministically in the
|
|
//! core, but "the core asked for 0xb8" is not the same claim as "the datagram
|
|
//! left the host with TOS 0xb8". These tests close that gap with real sockets:
|
|
//! a real [`AsyncClient`] against a real [`Server`], reading the traffic class
|
|
//! the kernel reports for each reply it receives.
|
|
//!
|
|
//! Linux only, because the receive-side ancillary metadata the client exposes
|
|
//! is. The production marking path is platform-independent and is compiled and
|
|
//! exercised everywhere; only this observation of it is not.
|
|
|
|
#![cfg(target_os = "linux")]
|
|
|
|
use std::{io, net::SocketAddr, time::Duration};
|
|
|
|
use irtt_client::{AsyncClient, ClientConfig, ClientEvent, OpenOutcome, PacketMeta, SocketConfig};
|
|
use irtt_proto::{Clock, ReceivedStats, StampAt};
|
|
use irtt_server::{Server, ServerConfig, ServerRuntimeError};
|
|
use tokio::{sync::oneshot, task::JoinHandle, time::timeout};
|
|
|
|
/// DSCP codepoints, as the client's configuration takes them, paired with the
|
|
/// raw TOS / Traffic Class byte each one occupies the upper six bits of. The
|
|
/// server negotiates and applies the raw byte; the shift is the client's
|
|
/// user-facing convention.
|
|
const EF: (u8, u8) = (46, 0xb8);
|
|
const CS1: (u8, u8) = (8, 0x20);
|
|
const UNMARKED: (u8, u8) = (0, 0x00);
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn an_echo_reply_leaves_the_host_carrying_the_negotiated_traffic_class() {
|
|
timeout(Duration::from_secs(5), async {
|
|
let (server, addr) = bound_server("127.0.0.1:0".parse().unwrap())
|
|
.await
|
|
.expect("IPv4 loopback must be available");
|
|
let running = spawn(server);
|
|
|
|
let mut session = opened_session(addr, EF.0).await;
|
|
let meta = session.echo_packet_meta().await;
|
|
assert_eq!(meta.traffic_class, Some(EF.1), "the raw negotiated byte");
|
|
assert_eq!(meta.dscp, Some(EF.0), "its upper six bits");
|
|
assert_eq!(meta.ecn, Some(0), "and its low two");
|
|
|
|
running.stop().await;
|
|
})
|
|
.await
|
|
.expect("the wire traffic-class test exceeded its bounded runtime");
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn one_listener_serving_many_sessions_leaks_no_marking_between_them() {
|
|
// The invariant that justifies applying the class before *every* send. One
|
|
// socket carries all three sessions, so a reply that did not set its own
|
|
// class would go out under whichever one the previous reply left behind.
|
|
// The order below returns to a marked session after another has sent, and
|
|
// ends on the unmarked one, which is the case a "skip the call for zero"
|
|
// optimization would break.
|
|
timeout(Duration::from_secs(10), async {
|
|
let (server, addr) = bound_server("127.0.0.1:0".parse().unwrap())
|
|
.await
|
|
.expect("IPv4 loopback must be available");
|
|
let running = spawn(server);
|
|
|
|
const MARKED: usize = 0;
|
|
const LIGHTLY_MARKED: usize = 1;
|
|
const UNMARKED_SESSION: usize = 2;
|
|
let mut sessions = [
|
|
opened_session(addr, EF.0).await,
|
|
opened_session(addr, CS1.0).await,
|
|
opened_session(addr, UNMARKED.0).await,
|
|
];
|
|
|
|
for (label, session, expected) in [
|
|
("first marked echo", MARKED, EF.1),
|
|
("a differently marked session", LIGHTLY_MARKED, CS1.1),
|
|
("back to the first session", MARKED, EF.1),
|
|
("an unmarked session last", UNMARKED_SESSION, UNMARKED.1),
|
|
] {
|
|
let meta = sessions[session].echo_packet_meta().await;
|
|
assert_eq!(
|
|
meta.traffic_class,
|
|
Some(expected),
|
|
"{label}: each reply carries its own session's marking"
|
|
);
|
|
}
|
|
|
|
running.stop().await;
|
|
})
|
|
.await
|
|
.expect("the cross-session leakage test exceeded its bounded runtime");
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn an_ipv6_echo_reply_carries_the_negotiated_traffic_class() {
|
|
// One marked echo is enough here: the cross-session matrix above already
|
|
// pins the policy, and what this adds is the IPv6 Traffic Class option
|
|
// rather than the IPv4 TOS one.
|
|
timeout(Duration::from_secs(5), async {
|
|
let Some((server, addr)) = bound_server("[::1]:0".parse().unwrap()).await else {
|
|
return;
|
|
};
|
|
let running = spawn(server);
|
|
|
|
let mut session = opened_session(addr, EF.0).await;
|
|
let meta = session.echo_packet_meta().await;
|
|
assert_eq!(meta.traffic_class, Some(EF.1));
|
|
|
|
running.stop().await;
|
|
})
|
|
.await
|
|
.expect("the IPv6 wire traffic-class test exceeded its bounded runtime");
|
|
}
|
|
|
|
/// Binds a listener, or reports the family as unavailable on this host.
|
|
///
|
|
/// Only an address family this host does not have is skipped; any other bind
|
|
/// failure is a real failure.
|
|
async fn bound_server(addr: SocketAddr) -> Option<(Server, SocketAddr)> {
|
|
let server = match Server::bind(addr, unthrottled()).await {
|
|
Ok(server) => server,
|
|
Err(ServerRuntimeError::Bind { source, .. })
|
|
if matches!(
|
|
source.kind(),
|
|
io::ErrorKind::AddrNotAvailable | io::ErrorKind::Unsupported
|
|
) =>
|
|
{
|
|
return None;
|
|
}
|
|
Err(error) => panic!("unexpected loopback bind failure: {error}"),
|
|
};
|
|
let addr = server.local_addr().unwrap();
|
|
Some((server, addr))
|
|
}
|
|
|
|
/// A server that rate-limits nothing, so the cadence these tests drive replies
|
|
/// at cannot turn a marking assertion into a rate-limiting one.
|
|
fn unthrottled() -> ServerConfig {
|
|
ServerConfig::default().with_min_send_interval(Duration::ZERO)
|
|
}
|
|
|
|
/// A running listener and the handle that stops it.
|
|
struct Running {
|
|
shutdown: oneshot::Sender<()>,
|
|
task: JoinHandle<Result<(), ServerRuntimeError>>,
|
|
}
|
|
|
|
impl Running {
|
|
async fn stop(self) {
|
|
self.shutdown.send(()).unwrap();
|
|
self.task.await.unwrap().unwrap();
|
|
}
|
|
}
|
|
|
|
fn spawn(mut server: Server) -> Running {
|
|
let (shutdown, shutdown_rx) = oneshot::channel();
|
|
let task = tokio::spawn(async move {
|
|
server
|
|
.run(async {
|
|
let _ = shutdown_rx.await;
|
|
})
|
|
.await
|
|
});
|
|
Running { shutdown, task }
|
|
}
|
|
|
|
/// One client with an open session, and the sequence number its next echo will
|
|
/// carry — which the caller cannot infer once several sessions are interleaved.
|
|
struct OpenSession {
|
|
client: AsyncClient,
|
|
next_sequence: u32,
|
|
}
|
|
|
|
impl OpenSession {
|
|
/// Sends one echo and returns the receive metadata of its reply.
|
|
async fn echo_packet_meta(&mut self) -> PacketMeta {
|
|
let sequence = self.next_sequence;
|
|
self.next_sequence += 1;
|
|
self.client.send_probe().await.unwrap();
|
|
loop {
|
|
for event in self.client.recv().await.unwrap() {
|
|
if let ClientEvent::EchoReply {
|
|
seq, packet_meta, ..
|
|
} = event
|
|
{
|
|
assert_eq!(seq, sequence);
|
|
return packet_meta;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A client with an open session requesting `dscp` as a codepoint.
|
|
async fn opened_session(server_addr: SocketAddr, dscp: u8) -> OpenSession {
|
|
let config = ClientConfig {
|
|
server_addr: server_addr.to_string(),
|
|
duration: Some(Duration::from_secs(30)),
|
|
interval: Duration::from_millis(10),
|
|
length: 64,
|
|
dscp,
|
|
received_stats: ReceivedStats::Both,
|
|
stamp_at: StampAt::Both,
|
|
clock: Clock::Both,
|
|
open_timeouts: vec![Duration::from_millis(500)],
|
|
socket_config: SocketConfig {
|
|
recv_timeout: Some(Duration::from_millis(500)),
|
|
..SocketConfig::default()
|
|
},
|
|
probe_timeout: Duration::from_millis(500),
|
|
..ClientConfig::default()
|
|
};
|
|
let mut client = AsyncClient::connect(config).await.unwrap();
|
|
match client.open().await.unwrap() {
|
|
OpenOutcome::Started { negotiated, .. } => assert_eq!(
|
|
negotiated.params.dscp,
|
|
i64::from(dscp) << 2,
|
|
"the server negotiates the raw byte the codepoint asks for"
|
|
),
|
|
OpenOutcome::NoTestCompleted { .. } => panic!("a normal client unexpectedly ran no-test"),
|
|
}
|
|
OpenSession {
|
|
client,
|
|
next_sequence: 0,
|
|
}
|
|
}
|