tests: rework the actually useful tests to live in the integration crate
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 34s
Rust CI / cargo clippy (push) Successful in 35s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 10s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m14s
Rust CI / cargo doc (push) Successful in 23s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 29s
Supply chain / cargo-deny advisories (push) Successful in 9s
Supply chain / cargo-deny bans licenses sources (push) Successful in 5s
Supply chain / actionlint (push) Successful in 3s
Netem Interop / netem interop (push) Successful in 1m11s
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 34s
Rust CI / cargo clippy (push) Successful in 35s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 10s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m14s
Rust CI / cargo doc (push) Successful in 23s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 29s
Supply chain / cargo-deny advisories (push) Successful in 9s
Supply chain / cargo-deny bans licenses sources (push) Successful in 5s
Supply chain / actionlint (push) Successful in 3s
Netem Interop / netem interop (push) Successful in 1m11s
This commit is contained in:
101 files changed
+3636
-15159
No files matched your search
@@ -333,9 +333,9 @@ effective parameters must be safe for the server to run.
|
||||
- The transition is pure and is committed only after the reply has encoded, so
|
||||
an internal encoding failure cannot leave a session claiming to have answered
|
||||
a request it never did.
|
||||
- Clock sampling is a private injected seam, like token generation, not a
|
||||
runtime abstraction: the receive instant is taken as soon as a datagram is
|
||||
classified as an echo and the send instant just before the reply is built, so
|
||||
- Clock sampling uses the system clock directly, not a runtime abstraction: the
|
||||
receive instant is taken as soon as a datagram is classified as an echo and
|
||||
the send instant just before the reply is built, so
|
||||
they bracket the server's own handling. The monotonic origin belongs to the
|
||||
clock source and is stable for its life.
|
||||
- A reply's receive instant is held back to its send instant where the wall
|
||||
@@ -601,16 +601,22 @@ half-created session.
|
||||
maintain shadow encoders for well-formed wire data.
|
||||
- Hand-build packets only where the point is a payload a compliant encoder
|
||||
cannot produce — truncated varints, out-of-range enums, corrupted MACs.
|
||||
- Token generation and clock sampling are the only nondeterministic parts; tests
|
||||
inject a scripted source for each, so identity, collisions, allocation failure
|
||||
and timestamp values are all assertable. Keep both seams private, and keep
|
||||
timestamp, rate and lifetime tests free of sleeps, tolerances and real
|
||||
wall-clock assertions.
|
||||
- Two clock fakes, for two different questions. A scripted clock returns a fixed
|
||||
list of samples and is for tests that assert individual timestamp *readings*; a
|
||||
hand-moved clock stands still until the test moves it, and is for rate and
|
||||
lifetime tests, which care when a datagram arrived and not how many times the
|
||||
core read the clock on the way. Prefer the latter for anything about deadlines.
|
||||
- Prefer the public `ServerCore` packet interface and real runtime sockets for
|
||||
durable behavior. Use production encoders and decoders, actual OS-generated
|
||||
tokens, and observable replies/session capacity rather than private state.
|
||||
- Observe nonzero rate refill and lifetime deadlines with real time when an
|
||||
oversleep cannot invalidate the assertion. Bound observation loops and report
|
||||
useful failures. Do not restore fake clocks, random-source injection, race
|
||||
gates, synthetic ancillary classifiers or private observability merely to
|
||||
reproduce the deleted test structure. Focused fault injection remains allowed
|
||||
under the root policy only when no reasonable production-interface alternative
|
||||
can protect an important invariant.
|
||||
- Random fill's OS-source failure cannot be forced through the packet interface.
|
||||
Its zero-fill fallback remains an explicitly unexercised error path; ordinary
|
||||
random-fill wire tests must not claim to cover it or add an injection seam
|
||||
merely for coverage. Existing token-allocation fault tests protect bounded
|
||||
allocation and transactional failure, which cannot be driven reliably with
|
||||
actual random draws.
|
||||
- Assert rate and lifetime behavior from replies: which datagrams came back, the
|
||||
count and window they reported, and `session_count`. The token bucket is a
|
||||
black-box inference about the reference server, not a protocol requirement, so
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
//! Clock sampling for echo timestamps.
|
||||
//!
|
||||
//! An echo reply reports when the server received the request and when it sent
|
||||
//! the answer, so the core needs a clock. It takes one through this private
|
||||
//! seam rather than reading the system clock inline, so that timestamp behavior
|
||||
//! is deterministically testable without a runtime, sleeps or timing
|
||||
//! tolerances.
|
||||
//!
|
||||
//! This is the clock counterpart of [`TokenSource`](crate::token::TokenSource)
|
||||
//! and nothing more. It is **not** a runtime abstraction, not a transport
|
||||
//! boundary and not a pluggable product API: it is crate-private and the public
|
||||
//! constructor still takes only a [`ServerConfig`](crate::ServerConfig).
|
||||
//!
|
||||
//! The public [`ServerCore::handle_datagram`](crate::ServerCore::handle_datagram)
|
||||
//! entry point takes no timestamp: the core samples every instant it reports.
|
||||
//! The Tokio runtime uses a crate-private entry point that may additionally hand
|
||||
@@ -21,10 +10,7 @@
|
||||
//! one that models transport metadata. A [`ClockSample`] remains one paired
|
||||
//! userspace instant and never carries a kernel reading.
|
||||
|
||||
use std::{
|
||||
fmt,
|
||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// One instant, read from both clock domains together.
|
||||
///
|
||||
@@ -77,19 +63,6 @@ impl ClockSample {
|
||||
}
|
||||
}
|
||||
|
||||
/// A source of clock samples.
|
||||
///
|
||||
/// Deliberately private, like [`TokenSource`](crate::token::TokenSource): it
|
||||
/// exists so tests can script the receive and send instants of a reply, not as
|
||||
/// an extension point. The production implementation is [`SystemClock`].
|
||||
///
|
||||
/// Sampling is infallible. A clock that could refuse would make an admitted
|
||||
/// echo fail for a reason that is neither the peer's fault nor recoverable, so
|
||||
/// the production source saturates rather than reporting an error.
|
||||
pub(crate) trait ClockSource: fmt::Debug + Send {
|
||||
fn sample(&mut self) -> ClockSample;
|
||||
}
|
||||
|
||||
/// Reads the wall clock from [`SystemTime`] and the monotonic clock from an
|
||||
/// [`Instant`] captured when the source was created.
|
||||
///
|
||||
@@ -115,8 +88,8 @@ impl Default for SystemClock {
|
||||
}
|
||||
}
|
||||
|
||||
impl ClockSource for SystemClock {
|
||||
fn sample(&mut self) -> ClockSample {
|
||||
impl SystemClock {
|
||||
pub(crate) fn sample(&mut self) -> ClockSample {
|
||||
ClockSample {
|
||||
wall_ns: wall_ns(),
|
||||
mono_ns: saturating_ns(self.origin.elapsed()),
|
||||
@@ -136,7 +109,7 @@ fn wall_ns() -> i64 {
|
||||
/// does not fit the wire's signed-nanosecond field.
|
||||
///
|
||||
/// This is the conversion for an instant the server did **not** read from its
|
||||
/// own [`ClockSource`] — today, a kernel receive timestamp the transport
|
||||
/// own clock — today, a kernel receive timestamp the transport
|
||||
/// observed. It reports unrepresentability rather than saturating, because a
|
||||
/// clamped instant is indistinguishable from a real one at the boundary and
|
||||
/// would then be compared against a genuine sample as though it were plausible.
|
||||
@@ -183,88 +156,3 @@ fn mean_ns(a: i64, b: i64) -> i64 {
|
||||
i64::MAX
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Constructed values only: the production clock's real readings are not
|
||||
/// assertable without a timing tolerance, and the behavioral echo tests use
|
||||
/// a scripted source instead.
|
||||
#[test]
|
||||
fn a_midpoint_is_the_per_domain_mean_and_cannot_overflow() {
|
||||
let receive = ClockSample {
|
||||
wall_ns: 1_000,
|
||||
mono_ns: 10_000,
|
||||
};
|
||||
let send = ClockSample {
|
||||
wall_ns: 1_200,
|
||||
mono_ns: 10_300,
|
||||
};
|
||||
assert_eq!(
|
||||
receive.midpoint(send),
|
||||
ClockSample {
|
||||
wall_ns: 1_100,
|
||||
mono_ns: 10_150,
|
||||
}
|
||||
);
|
||||
|
||||
// The sum overflows `i64`; the mean does not.
|
||||
let high = ClockSample {
|
||||
wall_ns: i64::MAX,
|
||||
mono_ns: i64::MAX,
|
||||
};
|
||||
assert_eq!(
|
||||
high.midpoint(high),
|
||||
ClockSample {
|
||||
wall_ns: i64::MAX,
|
||||
mono_ns: i64::MAX,
|
||||
}
|
||||
);
|
||||
let low = ClockSample {
|
||||
wall_ns: i64::MIN,
|
||||
mono_ns: i64::MIN,
|
||||
};
|
||||
assert_eq!(
|
||||
low.midpoint(low),
|
||||
ClockSample {
|
||||
wall_ns: i64::MIN,
|
||||
mono_ns: i64::MIN,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
high.midpoint(low),
|
||||
ClockSample {
|
||||
wall_ns: 0,
|
||||
mono_ns: 0,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_duration_beyond_the_wire_field_saturates_rather_than_panicking() {
|
||||
assert_eq!(saturating_ns(Duration::from_nanos(1_500)), 1_500);
|
||||
assert_eq!(saturating_ns(Duration::MAX), i64::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_observed_instant_converts_or_reports_that_it_cannot() {
|
||||
assert_eq!(
|
||||
wall_ns_of(UNIX_EPOCH + Duration::from_nanos(1_500)),
|
||||
Some(1_500)
|
||||
);
|
||||
assert_eq!(wall_ns_of(UNIX_EPOCH), Some(0));
|
||||
|
||||
// Before the epoch is negative, not a wrap and not a rejection.
|
||||
assert_eq!(
|
||||
wall_ns_of(UNIX_EPOCH - Duration::from_nanos(1_500)),
|
||||
Some(-1_500)
|
||||
);
|
||||
|
||||
// Beyond the field, in either direction, is `None` rather than a
|
||||
// clamped value that would then read as an ordinary instant.
|
||||
let beyond = Duration::from_nanos(u64::MAX) + Duration::from_secs(1);
|
||||
assert_eq!(wall_ns_of(UNIX_EPOCH + beyond), None);
|
||||
assert_eq!(wall_ns_of(UNIX_EPOCH - beyond), None);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ use irtt_proto::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
clock::{saturating_ns, wall_ns_of, ClockSample, ClockSource, SystemClock},
|
||||
clock::{saturating_ns, wall_ns_of, ClockSample, SystemClock},
|
||||
config::ServerConfig,
|
||||
error::ServerError,
|
||||
fill::{echo_payload_len, negotiate_server_fill},
|
||||
@@ -27,8 +27,7 @@ use crate::{
|
||||
/// the bytes of one received datagram, and gets back the bytes to send to that
|
||||
/// endpoint, or nothing. Everything it does is a pure function of its state and
|
||||
/// that input, except for drawing session tokens and sampling the clock an echo
|
||||
/// reply's timestamps come from — both of which are private injected seams, so
|
||||
/// the whole engine stays deterministically testable.
|
||||
/// reply's timestamps come from — the clock is sampled directly and tokens have a private fault-injection seam.
|
||||
///
|
||||
/// The Tokio runtime wraps it directly:
|
||||
///
|
||||
@@ -88,7 +87,7 @@ pub struct ServerCore {
|
||||
config: ServerConfig,
|
||||
sessions: HashMap<u64, Session>,
|
||||
tokens: Box<dyn TokenSource>,
|
||||
clock: Box<dyn ClockSource>,
|
||||
clock: SystemClock,
|
||||
}
|
||||
|
||||
impl ServerCore {
|
||||
@@ -99,24 +98,13 @@ impl ServerCore {
|
||||
Self::with_token_source(config, Box::new(OsTokenSource))
|
||||
}
|
||||
|
||||
/// A core with a chosen token source and the production clock, which is
|
||||
/// what tests that care about session identity but not about timestamp
|
||||
/// values want.
|
||||
// Token fault injection covers random-source failures and allocation bounds.
|
||||
pub(crate) fn with_token_source(config: ServerConfig, tokens: Box<dyn TokenSource>) -> Self {
|
||||
Self::with_sources(config, tokens, Box::new(SystemClock::new()))
|
||||
}
|
||||
|
||||
/// A core with both nondeterministic sources chosen.
|
||||
pub(crate) fn with_sources(
|
||||
config: ServerConfig,
|
||||
tokens: Box<dyn TokenSource>,
|
||||
clock: Box<dyn ClockSource>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
sessions: HashMap::new(),
|
||||
tokens,
|
||||
clock,
|
||||
clock: SystemClock::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ impl FillMode {
|
||||
// region as unspecified residual buffer content, which returned
|
||||
// other traffic's bytes, and a compatible server must not.
|
||||
Self::None => Vec::new(),
|
||||
Self::Random => random_payload(len, getrandom::fill),
|
||||
Self::Random => random_payload(len),
|
||||
Self::Pattern(pattern) => pattern.iter().copied().cycle().take(len).collect(),
|
||||
}
|
||||
}
|
||||
@@ -132,25 +132,17 @@ fn hex_digit(digit: u8) -> Option<u8> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `len` random bytes drawn with `draw`, or `len` zeroes if the draw failed.
|
||||
/// `len` random bytes from the operating system, or zeroes if the draw failed.
|
||||
///
|
||||
/// `draw` is a parameter rather than a direct call so the failure path is
|
||||
/// assertable without a production hook: the caller passes the operating
|
||||
/// system's random source and one unit test passes a scripted one. An empty
|
||||
/// region asks the source for nothing at all.
|
||||
///
|
||||
/// **A failed draw zero-fills and is not an error.** Payload bytes carry no
|
||||
/// protocol meaning, so a random source having a bad afternoon must not cost a
|
||||
/// session its reply, let alone take the server down: the reply is still
|
||||
/// structurally valid and interoperable. The buffer is rewritten rather than
|
||||
/// trusted, because a failed draw leaves its contents unspecified.
|
||||
/// Failure is not fatal: payload variation has no protocol meaning, so a
|
||||
/// random-source failure must not terminate a listener or drop a session.
|
||||
///
|
||||
/// The bytes exist to vary the payload, and nothing here is a security claim.
|
||||
/// `getrandom` is used because the crate already depends on it; session tokens,
|
||||
/// which *are* security state, keep their own separate source.
|
||||
fn random_payload<E>(len: usize, draw: impl FnOnce(&mut [u8]) -> Result<(), E>) -> Vec<u8> {
|
||||
fn random_payload(len: usize) -> Vec<u8> {
|
||||
let mut payload = vec![0; len];
|
||||
if len > 0 && draw(&mut payload).is_err() {
|
||||
if len > 0 && getrandom::fill(&mut payload).is_err() {
|
||||
payload.fill(0);
|
||||
}
|
||||
payload
|
||||
@@ -223,21 +215,3 @@ pub(crate) fn echo_payload_len(hmac: bool, params: &Params) -> usize {
|
||||
let header_len = PacketLayout::echo(hmac, params).header_len();
|
||||
echo_packet_len(hmac, params).map_or(0, |len| len.saturating_sub(header_len))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_failed_random_draw_yields_zeroes() {
|
||||
// The real source cannot be made to fail without a production hook, and
|
||||
// the fallback itself is the policy under test: a failed draw must
|
||||
// produce a full-length payload of zeroes rather than an error or the
|
||||
// buffer's unspecified contents.
|
||||
let scribble = |buffer: &mut [u8]| {
|
||||
buffer.fill(0xab);
|
||||
Err(())
|
||||
};
|
||||
assert_eq!(random_payload(6, scribble), vec![0; 6]);
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,7 @@
|
||||
//! admission, authentication policy, open negotiation, the session table, echo
|
||||
//! receive state, rate and lifetime policy, resource decisions and reply
|
||||
//! construction. It performs no I/O; the clock its timestamps and deadlines
|
||||
//! come from is a private injected seam, not a runtime abstraction, so the
|
||||
//! engine stays testable. Each reply it produces is an [`OutboundDatagram`]:
|
||||
//! come from is sampled by the core. Each reply is an [`OutboundDatagram`]:
|
||||
//! the packet and the raw traffic class it must be sent with. [`Server`] owns
|
||||
//! one Tokio UDP listener and one core, and provides sequential receive, reply,
|
||||
//! scheduled expiry maintenance and caller-controlled shutdown. [`ServerSet`]
|
||||
|
||||
@@ -403,203 +403,7 @@ pub enum ServerRuntimeError {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{net::Ipv4Addr, time::Duration};
|
||||
|
||||
use irtt_proto::{
|
||||
decode_echo_reply, decode_open_reply, encode_request, Clock, Params, ReceivedStats,
|
||||
RequestToEncode, StampAt,
|
||||
};
|
||||
use tokio::{net::UdpSocket, sync::oneshot, time::timeout};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn bind_reports_local_address_and_shutdown_stops_run() {
|
||||
let requested = SocketAddr::from((Ipv4Addr::LOCALHOST, 0));
|
||||
let mut server = Server::bind(requested, ServerConfig::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let bound = server.local_addr().unwrap();
|
||||
assert_eq!(bound.ip(), requested.ip());
|
||||
assert_ne!(bound.port(), 0);
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
||||
shutdown_tx.send(()).unwrap();
|
||||
server
|
||||
.run(async {
|
||||
let _ = shutdown_rx.await;
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Construction is where a wildcard listener's promise is made or refused.
|
||||
///
|
||||
/// Both outcomes are asserted from the same code, because which one a target
|
||||
/// gets is exactly the thing under test: a target with a source-selection
|
||||
/// path configures it and serves; one without refuses the bind rather than
|
||||
/// starting a listener whose replies may come from an address no client
|
||||
/// contacted. An explicit bind is unaffected either way.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn from_socket_settles_wildcard_source_selection_before_serving_anything() {
|
||||
let explicit = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
assert!(Server::from_socket(explicit, ServerConfig::default()).is_ok());
|
||||
|
||||
let wildcard = UdpSocket::bind("0.0.0.0:0").await.unwrap();
|
||||
let wildcard = Server::from_socket(wildcard, ServerConfig::default());
|
||||
if socket_io::SUPPORTED {
|
||||
let server = wildcard.expect("a supported target configures source selection");
|
||||
assert!(server.select_reply_source);
|
||||
} else {
|
||||
assert!(matches!(
|
||||
wildcard,
|
||||
Err(ServerRuntimeError::WildcardSourceSelectionUnsupported { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn ipv4_open_echo_close_crosses_a_real_udp_socket() {
|
||||
exercise_udp_path(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))).await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn ipv6_open_echo_close_when_loopback_is_available() {
|
||||
let addr = "[::1]:0".parse().unwrap();
|
||||
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;
|
||||
}
|
||||
Err(error) => panic!("unexpected IPv6 loopback bind failure: {error}"),
|
||||
};
|
||||
exercise_bound_server(server).await;
|
||||
}
|
||||
|
||||
async fn exercise_udp_path(addr: SocketAddr) {
|
||||
let server = Server::bind(addr, unthrottled()).await.unwrap();
|
||||
exercise_bound_server(server).await;
|
||||
}
|
||||
|
||||
async fn exercise_bound_server(mut server: Server) {
|
||||
let server_addr = server.local_addr().unwrap();
|
||||
let client_bind = match server_addr {
|
||||
SocketAddr::V4(_) => "127.0.0.1:0",
|
||||
SocketAddr::V6(_) => "[::1]:0",
|
||||
};
|
||||
let client = UdpSocket::bind(client_bind).await.unwrap();
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
||||
let server_task = tokio::spawn(async move {
|
||||
server
|
||||
.run(async {
|
||||
let _ = shutdown_rx.await;
|
||||
})
|
||||
.await
|
||||
});
|
||||
|
||||
let requested = Params {
|
||||
protocol_version: 1,
|
||||
duration_ns: 1_000_000_000,
|
||||
interval_ns: 100_000_000,
|
||||
received_stats: ReceivedStats::Both,
|
||||
stamp_at: StampAt::Both,
|
||||
clock: Clock::Both,
|
||||
..Params::default()
|
||||
};
|
||||
let open = encode_request(
|
||||
RequestToEncode::Open {
|
||||
params: &requested,
|
||||
no_test: false,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
client.send_to(&open, server_addr).await.unwrap();
|
||||
|
||||
let mut buffer = vec![0; RECEIVE_BUFFER_LEN];
|
||||
let (len, reply_source) = timeout(Duration::from_secs(1), client.recv_from(&mut buffer))
|
||||
.await
|
||||
.expect("OPEN reply timeout")
|
||||
.unwrap();
|
||||
assert_eq!(reply_source, server_addr);
|
||||
let open_reply = decode_open_reply(&buffer[..len], None).unwrap();
|
||||
assert_ne!(open_reply.token, 0);
|
||||
|
||||
let echo = encode_request(
|
||||
RequestToEncode::Echo {
|
||||
token: open_reply.token,
|
||||
sequence: 0,
|
||||
params: &open_reply.params,
|
||||
payload: &[],
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
client.send_to(&echo, server_addr).await.unwrap();
|
||||
let (len, reply_source) = timeout(Duration::from_secs(1), client.recv_from(&mut buffer))
|
||||
.await
|
||||
.expect("ECHO reply timeout")
|
||||
.unwrap();
|
||||
assert_eq!(reply_source, server_addr);
|
||||
let echo_reply = decode_echo_reply(&buffer[..len], &open_reply.params, None).unwrap();
|
||||
assert_eq!(echo_reply.token, open_reply.token);
|
||||
assert_eq!(echo_reply.sequence, 0);
|
||||
assert_eq!(echo_reply.recv_count, Some(1));
|
||||
assert_eq!(echo_reply.recv_window, Some(1));
|
||||
|
||||
let close = encode_request(
|
||||
RequestToEncode::Close {
|
||||
token: open_reply.token,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
client.send_to(&close, server_addr).await.unwrap();
|
||||
client.send_to(&echo, server_addr).await.unwrap();
|
||||
assert!(
|
||||
timeout(Duration::from_millis(100), client.recv_from(&mut buffer))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
shutdown_tx.send(()).unwrap();
|
||||
server_task.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
fn unthrottled() -> ServerConfig {
|
||||
ServerConfig::default().with_min_send_interval(Duration::ZERO)
|
||||
}
|
||||
|
||||
/// Which bound addresses owe their replies a selected source.
|
||||
///
|
||||
/// The IPv4-mapped unspecified address is the one that is not obvious, and
|
||||
/// it is not hypothetical: Linux accepts `[::ffff:0.0.0.0]` as an IPv4
|
||||
/// wildcard bind and reports it back unchanged, so missing it would leave a
|
||||
/// working wildcard listener on the plain send path. A mapped address
|
||||
/// naming a real host address is explicit and must stay there.
|
||||
#[test]
|
||||
fn a_bound_address_naming_no_particular_local_address_is_a_wildcard() {
|
||||
for (addr, wildcard) in [
|
||||
("0.0.0.0:2112", true),
|
||||
("[::]:2112", true),
|
||||
("[::ffff:0.0.0.0]:2112", true),
|
||||
("127.0.0.1:2112", false),
|
||||
("192.0.2.10:2112", false),
|
||||
("[::1]:2112", false),
|
||||
("[::ffff:127.0.0.1]:2112", false),
|
||||
] {
|
||||
assert_eq!(
|
||||
is_wildcard(addr.parse().unwrap()),
|
||||
wildcard,
|
||||
"{addr} classified wrongly"
|
||||
);
|
||||
}
|
||||
}
|
||||
use super::may_send_unappliable;
|
||||
|
||||
/// The rule that decides what an unappliable traffic class means, which no
|
||||
/// normal interface can reach: it needs a host that refuses the socket
|
||||
|
||||
@@ -393,14 +393,6 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::{token::TokenSource, ServerCore, ServerError};
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn a_set_serving_nothing_is_refused() {
|
||||
assert!(matches!(
|
||||
ServerSet::bind([], ServerConfig::default()).await,
|
||||
Err(ServerSetError::NoListeners)
|
||||
));
|
||||
}
|
||||
|
||||
/// A token source that draws one value successfully and then fails every
|
||||
/// later draw.
|
||||
///
|
||||
|
||||
@@ -256,189 +256,3 @@ fn unsupported() -> io::Error {
|
||||
"reply source-address selection is unsupported on this target",
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Sends `payload` to a fresh explicit-address listener and returns what the
|
||||
/// transport layer decided about it, received into a `capacity`-byte buffer.
|
||||
///
|
||||
/// The buffer is deliberately tiny: the full-buffer rule is written against
|
||||
/// the caller's capacity, so it is provable without a 65,536-byte datagram.
|
||||
async fn receive_one(
|
||||
capacity: usize,
|
||||
payload: &[u8],
|
||||
) -> (io::Result<Option<ReceivedDatagram>>, Vec<u8>) {
|
||||
let listener = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
|
||||
let sender = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
|
||||
sender
|
||||
.send_to(payload, listener.local_addr().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut buffer = vec![0; capacity];
|
||||
let received = receive(&listener, &mut buffer, false).await;
|
||||
(received, buffer)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn a_datagram_shorter_than_the_buffer_reaches_the_core_intact() {
|
||||
let payload = b"irtt";
|
||||
let (received, buffer) = receive_one(16, payload).await;
|
||||
|
||||
let received = received
|
||||
.unwrap()
|
||||
.expect("a datagram that cannot have been truncated is accepted");
|
||||
assert_eq!(received.len, payload.len());
|
||||
assert_eq!(received.peer.ip(), Ipv4Addr::LOCALHOST);
|
||||
assert_eq!(received.reply_source, ReplySource::Bound);
|
||||
assert_eq!(&buffer[..received.len], payload);
|
||||
}
|
||||
|
||||
/// An exactly-full receive is ambiguous, not obviously fine: `recv_from`
|
||||
/// reports no truncation flag, so this datagram is indistinguishable from a
|
||||
/// larger one cut down to the same capacity. Both are dropped.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn a_datagram_filling_the_buffer_is_dropped_as_possibly_truncated() {
|
||||
let (received, _) = receive_one(4, b"irtt").await;
|
||||
assert!(received.unwrap().is_none());
|
||||
}
|
||||
|
||||
/// What matters is that an oversized datagram never reaches the core, not
|
||||
/// how the platform says so: Unix truncates it to the buffer, which the
|
||||
/// full-buffer rule then drops, while Windows fails the receive outright
|
||||
/// with `WSAEMSGSIZE`. Asserting the shared guarantee keeps this test true
|
||||
/// on both.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn a_datagram_larger_than_the_buffer_never_reaches_the_core() {
|
||||
let (received, _) = receive_one(4, b"irtt-rs").await;
|
||||
assert!(!matches!(received, Ok(Some(_))));
|
||||
}
|
||||
|
||||
/// A listener that never asked for kernel timestamps receives normally and
|
||||
/// reports none — the field is optional in the plainest sense, and its
|
||||
/// absence is not a defect to be worked around later.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn a_datagram_with_no_kernel_timestamp_is_served_all_the_same() {
|
||||
let (received, _) = receive_one(16, b"irtt").await;
|
||||
let received = received.unwrap().expect("an untimestamped datagram serves");
|
||||
assert_eq!(received.reply_source, ReplySource::Bound);
|
||||
assert_eq!(received.kernel_rx_timestamp, None);
|
||||
}
|
||||
|
||||
/// Linux-only coverage of the two receive paths, which is where the
|
||||
/// timestamp actually comes from.
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux {
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Sends `payload` from loopback to `listener` and receives it once.
|
||||
async fn exchange(
|
||||
listener: &UdpSocket,
|
||||
select_reply_source: bool,
|
||||
) -> (Option<ReceivedDatagram>, Vec<u8>) {
|
||||
let payload = b"irtt";
|
||||
let destination =
|
||||
SocketAddr::from((Ipv4Addr::LOCALHOST, listener.local_addr().unwrap().port()));
|
||||
let sender = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
|
||||
sender.send_to(payload, destination).await.unwrap();
|
||||
|
||||
let mut buffer = vec![0; 64];
|
||||
let received = receive(listener, &mut buffer, select_reply_source)
|
||||
.await
|
||||
.unwrap();
|
||||
(received, buffer)
|
||||
}
|
||||
|
||||
/// Reports the timestamp, or says why it is skipping the assertion
|
||||
/// rather than inventing one. Loopback under a normal kernel supplies
|
||||
/// it; a container or emulator that does not must not fail the run.
|
||||
fn timestamp_or_skip(received: &ReceivedDatagram, what: &str) {
|
||||
match received.kernel_rx_timestamp {
|
||||
Some(timestamp) => assert!(
|
||||
timestamp.duration_since(std::time::UNIX_EPOCH).is_ok(),
|
||||
"a kernel receive timestamp is a wall-clock instant"
|
||||
),
|
||||
None => eprintln!(
|
||||
"skipping {what} kernel timestamp assertion: \
|
||||
kernel did not provide SCM_TIMESTAMPNS"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The explicit-address listener's new `recvmsg` path: it must keep
|
||||
/// reporting exactly what `recv_from` did, and additionally see the
|
||||
/// timestamp `recv_from` had no way to retrieve.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn an_explicit_listener_receives_a_datagram_and_its_kernel_timestamp() {
|
||||
let listener = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
|
||||
assert!(
|
||||
try_configure_kernel_rx_timestamp(&listener),
|
||||
"Linux loopback accepts SO_TIMESTAMPNS"
|
||||
);
|
||||
|
||||
let (received, buffer) = exchange(&listener, false).await;
|
||||
let received = received.expect("an explicit listener serves a short datagram");
|
||||
|
||||
assert_eq!(received.len, 4);
|
||||
assert_eq!(&buffer[..received.len], b"irtt");
|
||||
assert_eq!(received.peer.ip(), Ipv4Addr::LOCALHOST);
|
||||
assert_eq!(received.reply_source, ReplySource::Bound);
|
||||
timestamp_or_skip(&received, "explicit");
|
||||
}
|
||||
|
||||
/// The regression this whole change risks: asking for a second control
|
||||
/// message must not crowd out the first. A wildcard listener with both
|
||||
/// enabled has to recover its destination *and* its timestamp from one
|
||||
/// datagram, with no `MSG_CTRUNC` drop and no dependence on the order
|
||||
/// the kernel returns them in.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn a_wildcard_listener_recovers_its_destination_and_timestamp_together() {
|
||||
let listener = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).await.unwrap();
|
||||
configure_destination_metadata(&listener, true).unwrap();
|
||||
assert!(try_configure_kernel_rx_timestamp(&listener));
|
||||
|
||||
let (received, buffer) = exchange(&listener, true).await;
|
||||
let received = received.expect(
|
||||
"a wildcard listener asking for two control messages still serves the datagram",
|
||||
);
|
||||
|
||||
assert_eq!(received.len, 4);
|
||||
assert_eq!(&buffer[..received.len], b"irtt");
|
||||
assert_eq!(received.peer.ip(), Ipv4Addr::LOCALHOST);
|
||||
assert_eq!(
|
||||
received.reply_source,
|
||||
ReplySource::V4 {
|
||||
address: Ipv4Addr::LOCALHOST
|
||||
},
|
||||
"the required destination survives the added timestamp request"
|
||||
);
|
||||
timestamp_or_skip(&received, "wildcard");
|
||||
}
|
||||
|
||||
/// The timestamp is not a precondition for either listener: a wildcard
|
||||
/// one that never enabled it still recovers the destination its reply
|
||||
/// depends on and serves the request.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn a_wildcard_listener_without_timestamps_still_recovers_its_destination() {
|
||||
let listener = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).await.unwrap();
|
||||
configure_destination_metadata(&listener, true).unwrap();
|
||||
|
||||
let (received, _) = exchange(&listener, true).await;
|
||||
let received = received.expect("no timestamp requested, and none needed");
|
||||
|
||||
assert_eq!(
|
||||
received.reply_source,
|
||||
ReplySource::V4 {
|
||||
address: Ipv4Addr::LOCALHOST
|
||||
}
|
||||
);
|
||||
assert_eq!(received.kernel_rx_timestamp, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -545,150 +545,3 @@ impl ControlBuffer {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Which reply sources need their interface named, which no normal
|
||||
/// interface can show: it takes a host whose route back to a peer leaves by
|
||||
/// a different interface than the request arrived on, and a test host has
|
||||
/// one loopback.
|
||||
#[test]
|
||||
fn only_a_link_local_source_pins_the_egress_interface() {
|
||||
const ARRIVED_ON: u32 = 7;
|
||||
|
||||
for (address, expected, why) in [
|
||||
(
|
||||
"fe80::1",
|
||||
ARRIVED_ON,
|
||||
"a link-local source needs its link named",
|
||||
),
|
||||
(
|
||||
"fe80::7858:baff:fe91:183d",
|
||||
ARRIVED_ON,
|
||||
"and so does any other one",
|
||||
),
|
||||
("2001:db8::1", 0, "a global source identifies itself"),
|
||||
("fd00::1", 0, "so does a unique-local one"),
|
||||
("::1", 0, "and loopback"),
|
||||
("::ffff:127.0.0.2", 0, "and an IPv4-mapped destination"),
|
||||
] {
|
||||
assert_eq!(
|
||||
egress_interface_index(address.parse().unwrap(), ARRIVED_ON),
|
||||
expected,
|
||||
"{address}: {why}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the two listener kinds do with the metadata they did or did not
|
||||
/// get, which is the whole distinction this module rests on: a destination
|
||||
/// is correctness-critical for one of them and irrelevant to the other.
|
||||
#[test]
|
||||
fn only_a_listener_that_needs_a_destination_is_stopped_by_a_missing_one() {
|
||||
let recovered = ReplySource::V4 {
|
||||
address: Ipv4Addr::LOCALHOST,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
resolved_reply_source(Some(recovered), true),
|
||||
Some(recovered),
|
||||
"a wildcard listener replies from the destination it recovered"
|
||||
);
|
||||
assert_eq!(
|
||||
resolved_reply_source(None, true),
|
||||
None,
|
||||
"and cannot answer a request whose destination did not arrive"
|
||||
);
|
||||
assert_eq!(
|
||||
resolved_reply_source(None, false),
|
||||
Some(ReplySource::Bound),
|
||||
"an explicit listener never needed one"
|
||||
);
|
||||
assert_eq!(
|
||||
resolved_reply_source(Some(recovered), false),
|
||||
Some(ReplySource::Bound),
|
||||
"and sends from its bind even if one turned up"
|
||||
);
|
||||
}
|
||||
|
||||
/// Which truncation is fatal to whom. A cut payload always is; cut control
|
||||
/// data is fatal only where it may have taken the destination with it.
|
||||
/// Turning a lost *timestamp* into a lost packet would trade a real client
|
||||
/// request for optional precision.
|
||||
#[test]
|
||||
fn control_truncation_only_condemns_a_datagram_whose_destination_it_may_have_taken() {
|
||||
for (flags, require_destination, unusable, why) in [
|
||||
(MsgFlags::empty(), true, false, "a clean wildcard receive"),
|
||||
(MsgFlags::empty(), false, false, "a clean explicit receive"),
|
||||
(
|
||||
MsgFlags::MSG_TRUNC,
|
||||
false,
|
||||
true,
|
||||
"a cut payload is not what the client sent",
|
||||
),
|
||||
(
|
||||
MsgFlags::MSG_TRUNC,
|
||||
true,
|
||||
true,
|
||||
"and no less so for a wildcard listener",
|
||||
),
|
||||
(
|
||||
MsgFlags::MSG_CTRUNC,
|
||||
true,
|
||||
true,
|
||||
"cut control data may have taken the destination",
|
||||
),
|
||||
(
|
||||
MsgFlags::MSG_CTRUNC,
|
||||
false,
|
||||
false,
|
||||
"but for an explicit listener it can only have cost a timestamp",
|
||||
),
|
||||
(
|
||||
MsgFlags::MSG_TRUNC | MsgFlags::MSG_CTRUNC,
|
||||
false,
|
||||
true,
|
||||
"the payload rule still applies",
|
||||
),
|
||||
] {
|
||||
assert_eq!(is_unusable(flags, require_destination), unusable, "{why}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A representable kernel reading becomes an instant; anything else becomes
|
||||
/// absent metadata. Note what is *not* rejected here: a reading far from
|
||||
/// now is structurally fine, and judging it belongs to whoever measures
|
||||
/// with it.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg_attr(target_env = "musl", allow(deprecated))]
|
||||
#[test]
|
||||
fn a_kernel_timestamp_converts_only_where_it_describes_an_instant() {
|
||||
assert_eq!(
|
||||
system_time_from_timespec(TimeSpec::new(1, 2))
|
||||
.unwrap()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap(),
|
||||
Duration::new(1, 2)
|
||||
);
|
||||
assert_eq!(
|
||||
system_time_from_timespec(TimeSpec::new(0, 0)),
|
||||
Some(UNIX_EPOCH),
|
||||
"the epoch itself is representable"
|
||||
);
|
||||
|
||||
assert_eq!(system_time_from_timespec(TimeSpec::new(-1, 0)), None);
|
||||
assert_eq!(system_time_from_timespec(TimeSpec::new(0, -1)), None);
|
||||
assert_eq!(
|
||||
system_time_from_timespec(TimeSpec::new(0, 1_000_000_000)),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
system_time_from_timespec(TimeSpec::new(libc::time_t::MIN, libc::c_long::MIN)),
|
||||
None,
|
||||
"and the extremes convert rather than panic"
|
||||
);
|
||||
let _ = system_time_from_timespec(TimeSpec::new(libc::time_t::MAX, 999_999_999));
|
||||
}
|
||||
}
|
||||
@@ -149,36 +149,3 @@ fn unsupported_traffic_class(_traffic_class: u32, option: &'static str) -> io::R
|
||||
format!("{option} are unsupported on this target"),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Which listeners mark with the IPv4 option.
|
||||
///
|
||||
/// The mapped forms are the point: such a socket is `AF_INET6` but sends
|
||||
/// IPv4, and `IPV6_TCLASS` does not reach those packets. Linux marks them
|
||||
/// through `IP_TOS`; the platforms that would reject that keep the IPv6
|
||||
/// option, because an unappliable marking stops the send and a dropped
|
||||
/// reply is worse than an unmarked one.
|
||||
#[test]
|
||||
fn a_mapped_listener_marks_with_the_ipv4_option_where_that_option_works() {
|
||||
let mapped = cfg!(target_os = "linux");
|
||||
|
||||
for (addr, is_ipv4) in [
|
||||
("0.0.0.0:2112", true),
|
||||
("127.0.0.1:2112", true),
|
||||
("[::ffff:0.0.0.0]:2112", mapped),
|
||||
("[::ffff:127.0.0.1]:2112", mapped),
|
||||
("[::]:2112", false),
|
||||
("[::1]:2112", false),
|
||||
("[2001:db8::1]:2112", false),
|
||||
] {
|
||||
assert_eq!(
|
||||
marks_with_ipv4_option(addr.parse().unwrap()),
|
||||
is_ipv4,
|
||||
"{addr} would mark with the wrong option"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
//! The structural admission boundary.
|
||||
//!
|
||||
//! A datagram that never decodes as a request is discarded before any request
|
||||
//! kind is chosen, so it can reach neither the session table nor an echo's
|
||||
//! receive state.
|
||||
|
||||
use super::support::{client_params, core_for, open_request, open_session, peer, ScriptedTokens};
|
||||
|
||||
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||
|
||||
#[test]
|
||||
fn structurally_invalid_datagrams_are_discarded() {
|
||||
let mut core = core_for(None, ScriptedTokens::new([TOKEN_A]));
|
||||
open_session(&mut core, peer(), None);
|
||||
let session = core.session(TOKEN_A).cloned().expect("session must exist");
|
||||
|
||||
let mut bad_magic = open_request(&client_params(), None);
|
||||
bad_magic[0] ^= 0x01;
|
||||
|
||||
let mut reserved_flag = open_request(&client_params(), None);
|
||||
reserved_flag[3] |= 0x10;
|
||||
|
||||
let mut reply_flag = open_request(&client_params(), None);
|
||||
reply_flag[3] |= irtt_proto::FLAG_REPLY;
|
||||
|
||||
for (name, packet) in [
|
||||
("empty datagram", Vec::new()),
|
||||
("datagram shorter than the header", vec![0x14, 0xa7, 0x5b]),
|
||||
("bad magic", bad_magic),
|
||||
("reserved flag bit", reserved_flag),
|
||||
("reply flag", reply_flag),
|
||||
] {
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &packet).unwrap(),
|
||||
None,
|
||||
"{name} must be discarded"
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(core.session_count(), 1);
|
||||
assert_eq!(core.session(TOKEN_A), Some(&session));
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
//! Client-initiated close.
|
||||
//!
|
||||
//! A close request that matches a live token *and* arrived from that session's
|
||||
//! exact endpoint releases the session and is answered with nothing. Every
|
||||
//! other close — unknown, stale or zero token, foreign endpoint, failed
|
||||
//! authentication — is the same silence with the session left live, so these
|
||||
//! tests assert the table as well as the reply.
|
||||
|
||||
use std::net::{SocketAddr, SocketAddrV6};
|
||||
|
||||
use irtt_proto::{encode_request, RequestToEncode};
|
||||
|
||||
use super::support::{
|
||||
client_params, close_request, close_request_with_trailing, core_for, core_with_tokens,
|
||||
open_request, open_session, other_peer, peer, ScriptedTokens, KEY, OTHER_KEY,
|
||||
};
|
||||
use crate::ServerConfig;
|
||||
|
||||
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||
const TOKEN_B: u64 = 0x1112_1314_1516_1718;
|
||||
const UNKNOWN_TOKEN: u64 = 0x2122_2324_2526_2728;
|
||||
|
||||
#[test]
|
||||
fn a_valid_close_removes_the_session_and_answers_nothing() {
|
||||
for hmac_key in [None, Some(KEY)] {
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let mut core = core_for(hmac_key, tokens.clone());
|
||||
let token = open_session(&mut core, peer(), hmac_key);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
|
||||
// Exactly `None`, not an empty or acknowledging packet: version 1 has
|
||||
// no close reply, so the client learns nothing about the outcome.
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &close_request(token, hmac_key))
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
|
||||
assert_eq!(core.session_count(), 0);
|
||||
assert_eq!(core.session(token), None);
|
||||
assert_eq!(
|
||||
tokens.remaining(),
|
||||
0,
|
||||
"a close must not draw from the token source"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeated_close_is_a_stale_token_and_changes_nothing() {
|
||||
for hmac_key in [None, Some(KEY)] {
|
||||
let mut core = core_for(hmac_key, ScriptedTokens::new([TOKEN_A]));
|
||||
let token = open_session(&mut core, peer(), hmac_key);
|
||||
let close = close_request(token, hmac_key);
|
||||
|
||||
assert_eq!(core.handle_datagram(peer(), &close).unwrap(), None);
|
||||
assert_eq!(core.handle_datagram(peer(), &close).unwrap(), None);
|
||||
|
||||
assert_eq!(core.session_count(), 0, "nothing may be resurrected");
|
||||
assert_eq!(core.session(token), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_echo_after_a_close_is_a_stale_token() {
|
||||
// The same request would have been answered a moment earlier: releasing the
|
||||
// session takes its token with it, so there is nothing left to reach.
|
||||
for hmac_key in [None, Some(KEY)] {
|
||||
let mut core = core_for(hmac_key, ScriptedTokens::new([TOKEN_A]));
|
||||
let token = open_session(&mut core, peer(), hmac_key);
|
||||
let params = core.session(token).expect("session must exist").params();
|
||||
let echo = encode_request(
|
||||
RequestToEncode::Echo {
|
||||
token,
|
||||
sequence: 0,
|
||||
params,
|
||||
payload: &[],
|
||||
},
|
||||
hmac_key,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
core.handle_datagram(peer(), &close_request(token, hmac_key))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(core.handle_datagram(peer(), &echo).unwrap(), None);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
assert_eq!(core.session(token), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_close_from_any_other_endpoint_leaves_the_session_live() {
|
||||
// Possessing the token is not authority to close. The first four pairs
|
||||
// differ in an established identity component; the last differs only in
|
||||
// IPv6 zone, which is `irtt-rs` policy rather than observed behavior — see
|
||||
// `same_endpoint`. It is pinned so the choice cannot drift silently, not
|
||||
// because evidence settled it.
|
||||
for (bound, foreign) in [
|
||||
("198.51.100.7:41234", "198.51.100.7:41235"),
|
||||
("198.51.100.7:41234", "198.51.100.9:41234"),
|
||||
("198.51.100.7:41234", "[::ffff:198.51.100.7]:41234"),
|
||||
("[2001:db8::1]:2112", "[2001:db8::2]:2112"),
|
||||
("[fe80::1%3]:2112", "[fe80::1%4]:2112"),
|
||||
] {
|
||||
let bound = bound.parse().unwrap();
|
||||
let foreign = foreign.parse().unwrap();
|
||||
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
let token = open_session(&mut core, bound, None);
|
||||
let session = core.session(token).cloned().expect("session must exist");
|
||||
let close = close_request(token, None);
|
||||
|
||||
assert_eq!(
|
||||
core.handle_datagram(foreign, &close).unwrap(),
|
||||
None,
|
||||
"a close from {foreign} must be silent"
|
||||
);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
assert_eq!(
|
||||
core.session(token),
|
||||
Some(&session),
|
||||
"a close from {foreign} must not remove the session bound to {bound}"
|
||||
);
|
||||
|
||||
// The same request from the session's own endpoint does close it, so
|
||||
// the request itself was never the reason for the silence.
|
||||
assert_eq!(core.handle_datagram(bound, &close).unwrap(), None);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv6_flow_information_is_not_part_of_endpoint_identity() {
|
||||
// Identity is family, address, port and scope. The flow label is routing
|
||||
// metadata that may vary within one flow, so letting `SocketAddrV6`
|
||||
// equality decide would strand a session its own client could never close.
|
||||
let ip = "2001:db8::1".parse().unwrap();
|
||||
let unlabeled = SocketAddr::V6(SocketAddrV6::new(ip, 2112, 0, 0));
|
||||
let labeled = SocketAddr::V6(SocketAddrV6::new(ip, 2112, 0x000f_1234, 0));
|
||||
assert_ne!(
|
||||
unlabeled, labeled,
|
||||
"the two must differ only in flow information"
|
||||
);
|
||||
|
||||
for (bound, closing) in [(unlabeled, labeled), (labeled, unlabeled)] {
|
||||
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
let token = open_session(&mut core, bound, None);
|
||||
|
||||
assert_eq!(
|
||||
core.handle_datagram(closing, &close_request(token, None))
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
core.session_count(),
|
||||
0,
|
||||
"a close from {closing} must release the session opened from {bound}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_or_zero_token_closes_nothing() {
|
||||
// Zero is reserved for no-test replies and is never issued to a session, so
|
||||
// it can only ever miss the table.
|
||||
for (name, token) in [("an unknown token", UNKNOWN_TOKEN), ("a zero token", 0)] {
|
||||
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
let live = open_session(&mut core, peer(), None);
|
||||
let session = core.session(live).cloned().expect("session must exist");
|
||||
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &close_request(token, None))
|
||||
.unwrap(),
|
||||
None,
|
||||
"{name} must be answered with silence"
|
||||
);
|
||||
assert_eq!(core.session_count(), 1, "{name} must remove nothing");
|
||||
assert_eq!(core.session(live), Some(&session));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_close_that_fails_authentication_leaves_the_session_live() {
|
||||
// Authentication runs before the session is looked up, so a close that
|
||||
// fails it is indistinguishable from one bearing an unknown token — and it
|
||||
// must not be able to tear down an authenticated session.
|
||||
let mut core = core_for(Some(KEY), ScriptedTokens::new([TOKEN_A]));
|
||||
let token = open_session(&mut core, peer(), Some(KEY));
|
||||
let session = core.session(token).cloned().expect("session must exist");
|
||||
|
||||
let mut corrupted = close_request(token, Some(KEY));
|
||||
corrupted[5] ^= 0x80;
|
||||
|
||||
for (name, packet) in [
|
||||
(
|
||||
"a close with no MAC field at all",
|
||||
close_request(token, None),
|
||||
),
|
||||
(
|
||||
"a close signed with a different key",
|
||||
close_request(token, Some(OTHER_KEY)),
|
||||
),
|
||||
("a close with one bit flipped in the MAC", corrupted),
|
||||
] {
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &packet).unwrap(),
|
||||
None,
|
||||
"{name} must be answered with silence"
|
||||
);
|
||||
assert_eq!(core.session_count(), 1, "{name} must not remove a session");
|
||||
assert_eq!(core.session(token), Some(&session));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_authenticating_server_ignores_an_authenticated_close() {
|
||||
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
let token = open_session(&mut core, peer(), None);
|
||||
let session = core.session(token).cloned().expect("session must exist");
|
||||
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &close_request(token, Some(KEY)))
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
|
||||
assert_eq!(core.session_count(), 1);
|
||||
assert_eq!(core.session(token), Some(&session));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_bytes_after_the_token_do_not_prevent_the_close() {
|
||||
for hmac_key in [None, Some(KEY)] {
|
||||
let mut core = core_for(hmac_key, ScriptedTokens::new([TOKEN_A]));
|
||||
let token = open_session(&mut core, peer(), hmac_key);
|
||||
|
||||
let close = close_request_with_trailing(token, &[0xde, 0xad, 0xbe, 0xef], hmac_key);
|
||||
|
||||
assert_eq!(core.handle_datagram(peer(), &close).unwrap(), None);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_close_removes_only_the_session_it_names() {
|
||||
// Repeated opens from one endpoint are independent sessions, so a close
|
||||
// must be resolved by token, never by endpoint.
|
||||
let mut core = core_with_tokens(
|
||||
ServerConfig::default(),
|
||||
ScriptedTokens::new([TOKEN_A, TOKEN_B]),
|
||||
);
|
||||
let first = open_session(&mut core, peer(), None);
|
||||
let second = open_session(&mut core, peer(), None);
|
||||
let survivor = core.session(second).cloned().expect("session must exist");
|
||||
assert_eq!(core.session_count(), 2);
|
||||
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &close_request(first, None))
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
assert_eq!(core.session_count(), 1, "only one session may be released");
|
||||
assert_eq!(core.session(first), None);
|
||||
assert_eq!(core.session(second), Some(&survivor));
|
||||
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &close_request(second, None))
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_close_frees_session_capacity_immediately() {
|
||||
// The session table is the only record of how full the server is, so a
|
||||
// removal is reclaimed at once with no separate counter to drift.
|
||||
let tokens = ScriptedTokens::new([TOKEN_A, TOKEN_B]);
|
||||
let mut core = core_with_tokens(ServerConfig::default().with_max_sessions(1), tokens.clone());
|
||||
let request = open_request(&client_params(), None);
|
||||
|
||||
let first = open_session(&mut core, peer(), None);
|
||||
assert_eq!(
|
||||
core.handle_datagram(other_peer(), &request).unwrap(),
|
||||
None,
|
||||
"the bound is reached, so the second open is refused"
|
||||
);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
assert_eq!(
|
||||
tokens.remaining(),
|
||||
1,
|
||||
"a refused open must not commit a token"
|
||||
);
|
||||
|
||||
core.handle_datagram(peer(), &close_request(first, None))
|
||||
.unwrap();
|
||||
assert_eq!(core.session_count(), 0);
|
||||
assert_eq!(
|
||||
tokens.remaining(),
|
||||
1,
|
||||
"a close must not draw from the token source"
|
||||
);
|
||||
|
||||
let second = open_session(&mut core, other_peer(), None);
|
||||
assert_eq!(second, TOKEN_B);
|
||||
assert_ne!(second, 0);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
assert_eq!(core.session(second).unwrap().peer(), other_peer());
|
||||
}
|
||||
@@ -1,818 +0,0 @@
|
||||
//! Normal echo processing.
|
||||
//!
|
||||
//! An echo request that authenticates, fits the configured maximum, names a
|
||||
//! live session and arrives from that session's endpoint is answered from the
|
||||
//! session's negotiated parameters. Everything else is silence that leaves the
|
||||
//! receive state alone, which these tests prove from the wire — the count and
|
||||
//! window in the *next* accepted reply are what show that a rejected datagram
|
||||
//! changed nothing.
|
||||
//!
|
||||
//! The count and window vectors come from the clean
|
||||
//! `test-vectors/SERVER_BEHAVIORAL_VECTORS.md` Section 1 and are hard-coded
|
||||
//! here rather than recomputed, so that replacing the observed semantics with a
|
||||
//! conventional selective-acknowledgement bitmap fails these tests.
|
||||
|
||||
use std::net::{SocketAddr, SocketAddrV6};
|
||||
|
||||
use irtt_proto::{
|
||||
echo_packet_len, verify_packet_hmac, Clock, Params, ReceivedStats, StampAt, TimestampFields,
|
||||
};
|
||||
|
||||
use super::support::{
|
||||
core_for, core_with_sources, core_with_tokens, echo_params, echo_request, expect_echo_reply,
|
||||
open_negotiated, other_peer, peer, sample, unthrottled, ScriptedClock, ScriptedTokens, KEY,
|
||||
OTHER_KEY,
|
||||
};
|
||||
use crate::{clock::ClockSample, ServerConfig, TimestampAllowance};
|
||||
|
||||
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||
const UNKNOWN_TOKEN: u64 = 0x2122_2324_2526_2728;
|
||||
|
||||
/// The lifecycle instant an open consumes before any echo is sent.
|
||||
///
|
||||
/// The core reads the clock once per authenticated request, to judge idle expiry
|
||||
/// and to start the new session's deadlines. It is earlier than every echo
|
||||
/// sample below, as a real monotonic reading would be, and no reply ever carries
|
||||
/// it: an open reply has no timestamp fields.
|
||||
fn open_sample() -> ClockSample {
|
||||
sample(900, 9_000)
|
||||
}
|
||||
|
||||
/// Params selecting no optional field at all, so the echo layout is the
|
||||
/// mandatory field block and nothing else.
|
||||
fn bare_params() -> Params {
|
||||
echo_params(
|
||||
ReceivedStats::None,
|
||||
StampAt::None,
|
||||
Clock::Unspecified,
|
||||
/* length */ 0,
|
||||
)
|
||||
}
|
||||
|
||||
/// Params reporting both statistics, which is what most vectors are stated in.
|
||||
fn counting_params(length: i64) -> Params {
|
||||
echo_params(
|
||||
ReceivedStats::Both,
|
||||
StampAt::None,
|
||||
Clock::Unspecified,
|
||||
length,
|
||||
)
|
||||
}
|
||||
|
||||
/// Opens one session and returns the core, the token and the negotiated params.
|
||||
///
|
||||
/// Rate limiting is disabled: these tests drive a whole vector of echoes at once
|
||||
/// against the production clock, and what they are about is the reply, not the
|
||||
/// allowance. `rate` covers the limiter.
|
||||
fn session_with(params: &Params) -> (crate::ServerCore, u64, Params) {
|
||||
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), params, None);
|
||||
(core, token, negotiated)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_valid_echo_is_answered_from_the_negotiated_params() {
|
||||
let (mut core, token, negotiated) = session_with(&bare_params());
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 42, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.expect("an admissible echo must be answered");
|
||||
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
assert_eq!(reply.token, token, "the session token is copied through");
|
||||
assert_eq!(
|
||||
reply.sequence, 42,
|
||||
"the sequence number is copied unchanged"
|
||||
);
|
||||
assert_eq!(reply.recv_count, None, "no statistics were negotiated");
|
||||
assert_eq!(reply.recv_window, None);
|
||||
assert_eq!(
|
||||
reply.timestamps,
|
||||
TimestampFields::default(),
|
||||
"no timestamps were negotiated, so none are emitted"
|
||||
);
|
||||
assert!(reply.payload.is_empty());
|
||||
assert_eq!(
|
||||
packet.bytes().len(),
|
||||
echo_packet_len(false, &negotiated).unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
packet.bytes().len(),
|
||||
16,
|
||||
"magic, flags, token and sequence only"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
core.session_count(),
|
||||
1,
|
||||
"an echo neither creates nor releases a session"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_is_sized_by_the_session_not_by_the_request() {
|
||||
// A request may be shorter or longer than the negotiated length, and its
|
||||
// bytes past the sequence number are opaque either way.
|
||||
let (mut core, token, negotiated) = session_with(&counting_params(64));
|
||||
assert_eq!(negotiated.length, 64);
|
||||
|
||||
let short = Params {
|
||||
length: 0,
|
||||
..negotiated.clone()
|
||||
};
|
||||
// Requesting no statistics moves this request's payload region up to where
|
||||
// the *session's* reply keeps its count and window, so the opaque bytes
|
||||
// land squarely on fields a server must not read from a request.
|
||||
let long = Params {
|
||||
length: 100,
|
||||
received_stats: ReceivedStats::None,
|
||||
..negotiated.clone()
|
||||
};
|
||||
|
||||
for (sequence, name, sizing, payload, expected) in [
|
||||
(
|
||||
0,
|
||||
"a request shorter than the negotiated length",
|
||||
&short,
|
||||
&[][..],
|
||||
(1, 0x1),
|
||||
),
|
||||
(
|
||||
1,
|
||||
"a request longer than the negotiated length, with opaque bytes",
|
||||
&long,
|
||||
&[0xa5; 40][..],
|
||||
(2, 0x3),
|
||||
),
|
||||
] {
|
||||
let request = echo_request(token, sequence, sizing, payload, None);
|
||||
assert_ne!(request.len(), 64, "{name} must not be the reply's length");
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &request)
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("{name} must be answered"));
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
|
||||
assert_eq!(
|
||||
packet.bytes().len(),
|
||||
64,
|
||||
"{name}: the reply is the negotiated length"
|
||||
);
|
||||
assert_eq!(reply.sequence, sequence);
|
||||
assert_eq!(
|
||||
(reply.recv_count, reply.recv_window),
|
||||
(Some(expected.0), Some(expected.1)),
|
||||
"{name}: the opaque tail must not influence the reply"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_echo_over_the_configured_maximum_is_dropped_before_anything_advances() {
|
||||
let config = ServerConfig::default().with_max_packet_length(64);
|
||||
let mut core = core_with_tokens(config, ScriptedTokens::new([TOKEN_A]));
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), &counting_params(64), None);
|
||||
assert_eq!(negotiated.length, 64);
|
||||
|
||||
let at_maximum = echo_request(token, 0, &negotiated, &[], None);
|
||||
assert_eq!(at_maximum.len(), 64);
|
||||
let over_maximum = echo_request(
|
||||
token,
|
||||
1,
|
||||
&Params {
|
||||
length: 65,
|
||||
..negotiated.clone()
|
||||
},
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
assert_eq!(over_maximum.len(), 65);
|
||||
|
||||
// The comparison is strict, so exactly the maximum is served.
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &at_maximum)
|
||||
.unwrap()
|
||||
.expect("a request of exactly the maximum must be answered");
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
assert_eq!((reply.recv_count, reply.recv_window), (Some(1), Some(0x1)));
|
||||
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &over_maximum).unwrap(),
|
||||
None,
|
||||
"one byte over the maximum must be answered with silence"
|
||||
);
|
||||
|
||||
// The next admissible request is what proves the drop advanced nothing: had
|
||||
// the oversized one counted, this would report 3 rather than 2.
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 1, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.expect("an admissible echo after a dropped one must be answered");
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
assert_eq!((reply.recv_count, reply.recv_window), (Some(2), Some(0x3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_echo_that_owns_no_session_here_is_answered_with_silence() {
|
||||
let (mut core, token, negotiated) = session_with(&counting_params(0));
|
||||
|
||||
for (name, endpoint, request_token) in [
|
||||
("an unknown token", peer(), UNKNOWN_TOKEN),
|
||||
("a zero token", peer(), 0),
|
||||
(
|
||||
"a valid token from another source port",
|
||||
other_peer(),
|
||||
token,
|
||||
),
|
||||
] {
|
||||
let request = echo_request(request_token, 0, &negotiated, &[], None);
|
||||
assert_eq!(
|
||||
core.handle_datagram(endpoint, &request).unwrap(),
|
||||
None,
|
||||
"{name} must be answered with silence"
|
||||
);
|
||||
}
|
||||
|
||||
// Count 1 and window 0x1 are the first-request values, so none of the three
|
||||
// rejected datagrams touched this session's receive state.
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.expect("the session's own echo must still be answered");
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
assert_eq!((reply.recv_count, reply.recv_window), (Some(1), Some(0x1)));
|
||||
assert_eq!(core.session_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_echo_resolves_ipv6_endpoints_by_the_same_identity_rule_as_a_close() {
|
||||
// The comparator itself is pinned by the close tests; this is the echo path
|
||||
// reaching the same one rather than growing a second rule.
|
||||
let global = "2001:db8::1".parse().unwrap();
|
||||
let link_local = "fe80::1".parse().unwrap();
|
||||
let unlabeled = SocketAddr::V6(SocketAddrV6::new(global, 2112, 0, 0));
|
||||
let labeled = SocketAddr::V6(SocketAddrV6::new(global, 2112, 0x000f_1234, 0));
|
||||
let zone_three = SocketAddr::V6(SocketAddrV6::new(link_local, 2112, 0, 3));
|
||||
let zone_four = SocketAddr::V6(SocketAddrV6::new(link_local, 2112, 0, 4));
|
||||
|
||||
for (bound, sender, answered) in [
|
||||
// Flow information is routing metadata that identifies no endpoint.
|
||||
(unlabeled, labeled, true),
|
||||
(labeled, unlabeled, true),
|
||||
// The zone is identity by `irtt-rs` policy, not by observed behavior.
|
||||
(zone_three, zone_four, false),
|
||||
] {
|
||||
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
let (token, negotiated) = open_negotiated(&mut core, bound, &counting_params(0), None);
|
||||
|
||||
let reply = core
|
||||
.handle_datagram(sender, &echo_request(token, 0, &negotiated, &[], None))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
reply.is_some(),
|
||||
answered,
|
||||
"an echo from {sender} on a session opened from {bound}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn received_count_and_window_follow_the_observed_vectors() {
|
||||
// Every row is `(request sequence, reply count, reply window)` exactly as
|
||||
// measured in Section 1 of the behavioral vectors.
|
||||
for (name, vectors) in [
|
||||
(
|
||||
"1.1 sequential, no loss",
|
||||
&[
|
||||
(0, 1, 0x1),
|
||||
(1, 2, 0x3),
|
||||
(2, 3, 0x7),
|
||||
(3, 4, 0xf),
|
||||
(4, 5, 0x1f),
|
||||
(5, 6, 0x3f),
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"1.2 one gap",
|
||||
&[(0, 1, 0x1), (1, 2, 0x3), (3, 3, 0xd), (4, 4, 0x1b)][..],
|
||||
),
|
||||
(
|
||||
"1.3 multiple gaps",
|
||||
&[(0, 1, 0x1), (3, 2, 0x9), (7, 3, 0x91), (8, 4, 0x123)][..],
|
||||
),
|
||||
(
|
||||
// Filling a gap late discards the history rather than setting the
|
||||
// bit the gap left clear.
|
||||
"1.4 later gap fill",
|
||||
&[(0, 1, 0x1), (1, 2, 0x3), (5, 3, 0x31), (3, 4, 0x1)][..],
|
||||
),
|
||||
(
|
||||
"1.5 duplicate sequence number",
|
||||
&[(0, 1, 0x1), (1, 2, 0x3), (1, 3, 0x3), (2, 4, 0x7)][..],
|
||||
),
|
||||
(
|
||||
// Row 5 rebuilds from the reset: bit 1 stays clear even though
|
||||
// sequence 2 was received before the reordering.
|
||||
"1.6 reordering",
|
||||
&[
|
||||
(0, 1, 0x1),
|
||||
(1, 2, 0x3),
|
||||
(2, 3, 0x7),
|
||||
(1, 4, 0x1),
|
||||
(3, 5, 0x5),
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"1.7 strictly descending sequence numbers",
|
||||
&[
|
||||
(5, 1, 0x1),
|
||||
(4, 2, 0x1),
|
||||
(3, 3, 0x1),
|
||||
(2, 4, 0x1),
|
||||
(1, 5, 0x1),
|
||||
(0, 6, 0x1),
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"1.8 a gap of exactly 63",
|
||||
&[(0, 1, 0x1), (63, 2, 0x8000_0000_0000_0001)][..],
|
||||
),
|
||||
("1.8 a gap of 64", &[(0, 1, 0x1), (64, 2, 0x1)][..]),
|
||||
("1.8 a gap of 65", &[(0, 1, 0x1), (65, 2, 0x1)][..]),
|
||||
("1.8 a gap of 1000", &[(0, 1, 0x1), (1000, 2, 0x1)][..]),
|
||||
(
|
||||
"1.9 a non-zero starting sequence number",
|
||||
&[(100, 1, 0x1), (101, 2, 0x3), (102, 3, 0x7)][..],
|
||||
),
|
||||
(
|
||||
"1.10 32-bit sequence wraparound",
|
||||
&[
|
||||
(0xffff_fffd, 1, 0x1),
|
||||
(0xffff_fffe, 2, 0x3),
|
||||
(0xffff_ffff, 3, 0x7),
|
||||
(0, 4, 0xf),
|
||||
(1, 5, 0x1f),
|
||||
][..],
|
||||
),
|
||||
] {
|
||||
let (mut core, token, negotiated) = session_with(&counting_params(0));
|
||||
|
||||
for &(sequence, count, window) in vectors {
|
||||
let packet = core
|
||||
.handle_datagram(
|
||||
peer(),
|
||||
&echo_request(token, sequence, &negotiated, &[], None),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("{name}: sequence {sequence} must be answered"));
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
|
||||
assert_eq!(
|
||||
reply.recv_count,
|
||||
Some(count),
|
||||
"{name}: count after sequence {sequence}"
|
||||
);
|
||||
assert_eq!(
|
||||
reply.recv_window,
|
||||
Some(window),
|
||||
"{name}: window after sequence {sequence}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_negotiated_received_statistics_are_reported() {
|
||||
// Field presence only; the transition itself is pinned by the vectors
|
||||
// above, so two in-order requests are enough here.
|
||||
for (stats, count, window) in [
|
||||
(ReceivedStats::None, None, None),
|
||||
(ReceivedStats::Count, Some(2), None),
|
||||
(ReceivedStats::Window, None, Some(0x3)),
|
||||
(ReceivedStats::Both, Some(2), Some(0x3)),
|
||||
] {
|
||||
let params = echo_params(stats, StampAt::None, Clock::Unspecified, 0);
|
||||
let (mut core, token, negotiated) = session_with(¶ms);
|
||||
|
||||
let mut reply = None;
|
||||
for sequence in 0..2 {
|
||||
let packet = core
|
||||
.handle_datagram(
|
||||
peer(),
|
||||
&echo_request(token, sequence, &negotiated, &[], None),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("{stats:?}: sequence {sequence} must be answered"));
|
||||
reply = Some(expect_echo_reply(&packet, &negotiated, None));
|
||||
}
|
||||
|
||||
let reply = reply.expect("two requests were sent");
|
||||
assert_eq!(reply.recv_count, count, "count presence for {stats:?}");
|
||||
assert_eq!(reply.recv_window, window, "window presence for {stats:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_authenticated_session_answers_only_authenticated_echoes() {
|
||||
let mut core = core_for(Some(KEY), ScriptedTokens::new([TOKEN_A]));
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), &counting_params(0), Some(KEY));
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 3, &negotiated, &[], Some(KEY)))
|
||||
.unwrap()
|
||||
.expect("a correctly authenticated echo must be answered");
|
||||
assert!(
|
||||
verify_packet_hmac(KEY, packet.bytes()).is_ok(),
|
||||
"the reply must carry a MAC that verifies with the server's key"
|
||||
);
|
||||
// `expect_echo_reply` pins the HMAC flag and verifies the MAC again while
|
||||
// decoding, so a reply that authenticated but omitted the flag would fail
|
||||
// here — the failure mode that silently breaks upstream clients.
|
||||
let reply = expect_echo_reply(&packet, &negotiated, Some(KEY));
|
||||
assert_eq!(reply.token, token);
|
||||
assert_eq!(reply.sequence, 3);
|
||||
assert_eq!((reply.recv_count, reply.recv_window), (Some(1), Some(0x1)));
|
||||
|
||||
let mut corrupted = echo_request(token, 4, &negotiated, &[], Some(KEY));
|
||||
corrupted[5] ^= 0x80;
|
||||
for (name, packet) in [
|
||||
(
|
||||
"an echo with no MAC field at all",
|
||||
echo_request(token, 4, &negotiated, &[], None),
|
||||
),
|
||||
(
|
||||
"an echo signed with a different key",
|
||||
echo_request(token, 4, &negotiated, &[], Some(OTHER_KEY)),
|
||||
),
|
||||
("an echo with one bit flipped in the MAC", corrupted),
|
||||
] {
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &packet).unwrap(),
|
||||
None,
|
||||
"{name} must be answered with silence"
|
||||
);
|
||||
}
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 4, &negotiated, &[], Some(KEY)))
|
||||
.unwrap()
|
||||
.expect("an authenticated echo after failed ones must be answered");
|
||||
let reply = expect_echo_reply(&packet, &negotiated, Some(KEY));
|
||||
assert_eq!(
|
||||
(reply.recv_count, reply.recv_window),
|
||||
(Some(2), Some(0x3)),
|
||||
"none of the packets that failed authentication may be counted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_negotiated_timestamps_are_emitted() {
|
||||
let received = sample(1_000, 10_000);
|
||||
let sent = sample(1_200, 10_300);
|
||||
|
||||
for (stamp_at, clock, expected) in [
|
||||
(
|
||||
StampAt::Send,
|
||||
Clock::Wall,
|
||||
TimestampFields {
|
||||
send_wall: Some(1_200),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Send,
|
||||
Clock::Monotonic,
|
||||
TimestampFields {
|
||||
send_mono: Some(10_300),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Send,
|
||||
Clock::Both,
|
||||
TimestampFields {
|
||||
send_wall: Some(1_200),
|
||||
send_mono: Some(10_300),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Receive,
|
||||
Clock::Wall,
|
||||
TimestampFields {
|
||||
recv_wall: Some(1_000),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Receive,
|
||||
Clock::Monotonic,
|
||||
TimestampFields {
|
||||
recv_mono: Some(10_000),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Receive,
|
||||
Clock::Both,
|
||||
TimestampFields {
|
||||
recv_wall: Some(1_000),
|
||||
recv_mono: Some(10_000),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Both,
|
||||
Clock::Wall,
|
||||
TimestampFields {
|
||||
recv_wall: Some(1_000),
|
||||
send_wall: Some(1_200),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Both,
|
||||
Clock::Monotonic,
|
||||
TimestampFields {
|
||||
recv_mono: Some(10_000),
|
||||
send_mono: Some(10_300),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Both,
|
||||
Clock::Both,
|
||||
TimestampFields {
|
||||
recv_wall: Some(1_000),
|
||||
recv_mono: Some(10_000),
|
||||
send_wall: Some(1_200),
|
||||
send_mono: Some(10_300),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Midpoint,
|
||||
Clock::Wall,
|
||||
TimestampFields {
|
||||
midpoint_wall: Some(1_100),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Midpoint,
|
||||
Clock::Monotonic,
|
||||
TimestampFields {
|
||||
midpoint_mono: Some(10_150),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Midpoint,
|
||||
Clock::Both,
|
||||
TimestampFields {
|
||||
midpoint_wall: Some(1_100),
|
||||
midpoint_mono: Some(10_150),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
] {
|
||||
let clock_source = ScriptedClock::new([open_sample(), received, sent]);
|
||||
let mut core = core_with_sources(
|
||||
ServerConfig::default(),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
clock_source.clone(),
|
||||
);
|
||||
let params = echo_params(ReceivedStats::None, stamp_at, clock, 0);
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), ¶ms, None);
|
||||
assert_eq!(
|
||||
clock_source.remaining(),
|
||||
2,
|
||||
"an open consumes exactly its own lifecycle sample"
|
||||
);
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("{stamp_at:?} on {clock:?} must be answered"));
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
|
||||
// Equality over the whole struct, so an unselected field appearing is a
|
||||
// failure as much as a selected one carrying the wrong instant.
|
||||
assert_eq!(reply.timestamps, expected, "{stamp_at:?} on {clock:?}");
|
||||
assert_eq!(
|
||||
packet.bytes().len(),
|
||||
echo_packet_len(false, &negotiated).unwrap(),
|
||||
"{stamp_at:?} on {clock:?} must be the negotiated layout's length"
|
||||
);
|
||||
assert_eq!(
|
||||
clock_source.remaining(),
|
||||
0,
|
||||
"{stamp_at:?} on {clock:?}: one receive and one send sample"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_backward_wall_step_never_reports_receive_after_send() {
|
||||
// A reply's receive instant must not be later than its send instant, and
|
||||
// the wall clock can be stepped backwards between the two readings by NTP,
|
||||
// a hypervisor or an administrator. The pair settles on the later reading,
|
||||
// and only the wall domain moves: the monotonic one was already ordered.
|
||||
let mut core = core_with_sources(
|
||||
ServerConfig::default(),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
ScriptedClock::new([open_sample(), sample(1_000, 10_000), sample(990, 10_300)]),
|
||||
);
|
||||
let params = echo_params(ReceivedStats::None, StampAt::Both, Clock::Both, 0);
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), ¶ms, None);
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.expect("a backward wall step must not stop the echo being answered");
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
|
||||
assert_eq!(
|
||||
reply.timestamps,
|
||||
TimestampFields {
|
||||
recv_wall: Some(990),
|
||||
recv_mono: Some(10_000),
|
||||
send_wall: Some(990),
|
||||
send_mono: Some(10_300),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
"the receive wall value is held back to the send reading, and only it"
|
||||
);
|
||||
assert!(reply.timestamps.recv_wall <= reply.timestamps.send_wall);
|
||||
assert!(reply.timestamps.recv_mono <= reply.timestamps.send_mono);
|
||||
|
||||
// A midpoint drawn from the same stepped pair is ordered for the same
|
||||
// reason: it is the mean of two values that are no longer inverted.
|
||||
let mut core = core_with_sources(
|
||||
ServerConfig::default(),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
ScriptedClock::new([open_sample(), sample(1_000, 10_000), sample(990, 10_300)]),
|
||||
);
|
||||
let params = echo_params(ReceivedStats::None, StampAt::Midpoint, Clock::Wall, 0);
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), ¶ms, None);
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.expect("a backward wall step must not stop the echo being answered");
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
|
||||
assert_eq!(
|
||||
reply.timestamps,
|
||||
TimestampFields {
|
||||
midpoint_wall: Some(990),
|
||||
..TimestampFields::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_clock_midpoint_emits_exactly_one_field() {
|
||||
// A guard against reproducing upstream 0.9.1's dual-field midpoint, which
|
||||
// emits both midpoint fields whatever the negotiated clock. `irtt-proto`
|
||||
// still decodes that form from a peer; this is about what this server
|
||||
// sends.
|
||||
for (clock, expected) in [
|
||||
(
|
||||
Clock::Wall,
|
||||
TimestampFields {
|
||||
midpoint_wall: Some(1_100),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
Clock::Monotonic,
|
||||
TimestampFields {
|
||||
midpoint_mono: Some(10_150),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
] {
|
||||
let mut core = core_with_sources(
|
||||
ServerConfig::default(),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
ScriptedClock::new([open_sample(), sample(1_000, 10_000), sample(1_200, 10_300)]),
|
||||
);
|
||||
let params = echo_params(ReceivedStats::None, StampAt::Midpoint, clock, 0);
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), ¶ms, None);
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("a midpoint echo on {clock:?} must be answered"));
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
|
||||
assert_eq!(
|
||||
packet.bytes().len(),
|
||||
echo_packet_len(false, &negotiated).unwrap(),
|
||||
"{clock:?}: the negotiated length, not the compatibility one"
|
||||
);
|
||||
assert_eq!(
|
||||
packet.bytes().len(),
|
||||
24,
|
||||
"{clock:?}: one 8-byte midpoint field, not the upstream two"
|
||||
);
|
||||
assert_eq!(reply.timestamps, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_payload_is_server_generated_and_never_reflects_the_request() {
|
||||
// The inbound tail is opaque, and ServerFill describes the *server's* bytes:
|
||||
// a request's payload is neither echoed, mixed in, nor used to seed
|
||||
// anything. This session expressed no fill preference, so the region
|
||||
// carries the server's default fill; `fill` covers the modes themselves.
|
||||
let params = echo_params(ReceivedStats::None, StampAt::None, Clock::Unspecified, 64);
|
||||
let (mut core, token, negotiated) = session_with(¶ms);
|
||||
|
||||
let request = echo_request(token, 0, &negotiated, &[0xa5; 48], None);
|
||||
assert!(
|
||||
request[16..].iter().all(|byte| *byte == 0xa5),
|
||||
"the request must carry non-zero payload bytes to reflect"
|
||||
);
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &request)
|
||||
.unwrap()
|
||||
.expect("an admissible echo must be answered");
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
|
||||
assert_eq!(packet.bytes().len(), 64);
|
||||
assert_eq!(reply.payload.len(), 48);
|
||||
assert!(
|
||||
!reply.payload.contains(&0xa5),
|
||||
"no request byte may appear in the reply payload"
|
||||
);
|
||||
assert!(
|
||||
reply
|
||||
.payload
|
||||
.chunks(4)
|
||||
.all(|chunk| chunk == &b"irtt"[..chunk.len()]),
|
||||
"the region carries the server's default fill"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_restricted_session_emits_the_restricted_timestamps_and_not_the_requested_ones() {
|
||||
// Timestamp generation follows the session's *negotiated* placement, which
|
||||
// is the point of restricting it during negotiation: a client asking for
|
||||
// both instants from both clocks gets one midpoint field per clock under a
|
||||
// single-timestamp allowance, and nothing at all under none — with the reply
|
||||
// laid out and sized from the restriction either way, not from the request.
|
||||
for (allowance, expected_stamp_at, expected, expected_len) in [
|
||||
(
|
||||
TimestampAllowance::Single,
|
||||
StampAt::Midpoint,
|
||||
TimestampFields {
|
||||
midpoint_wall: Some(1_100),
|
||||
midpoint_mono: Some(10_150),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
32,
|
||||
),
|
||||
(
|
||||
TimestampAllowance::None,
|
||||
StampAt::None,
|
||||
TimestampFields::default(),
|
||||
16,
|
||||
),
|
||||
] {
|
||||
let mut core = core_with_sources(
|
||||
unthrottled().with_timestamp_allowance(allowance),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
ScriptedClock::new([open_sample(), sample(1_000, 10_000), sample(1_200, 10_300)]),
|
||||
);
|
||||
let requested = echo_params(ReceivedStats::None, StampAt::Both, Clock::Both, 0);
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), &requested, None);
|
||||
assert_eq!(negotiated.stamp_at, expected_stamp_at, "{allowance:?}");
|
||||
assert_eq!(negotiated.clock, Clock::Both, "{allowance:?}");
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("{allowance:?}: an admissible echo must be answered"));
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
|
||||
// Equality over the whole struct, so a leftover receive or send field is
|
||||
// a failure as much as a missing midpoint one.
|
||||
assert_eq!(reply.timestamps, expected, "{allowance:?}");
|
||||
assert_eq!(
|
||||
packet.bytes().len(),
|
||||
echo_packet_len(false, &negotiated).unwrap(),
|
||||
"{allowance:?}: the restricted layout's length"
|
||||
);
|
||||
assert_eq!(
|
||||
packet.bytes().len(),
|
||||
expected_len,
|
||||
"{allowance:?}: no empty slot is kept for a restricted field"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
//! Server fill: what an open reply reports, what the session will fill with,
|
||||
//! and the bytes an echo reply actually carries.
|
||||
//!
|
||||
//! A client that expressed no preference keeps its absent or empty descriptor
|
||||
//! in the negotiated parameters. Payload tests assert the effective fill from
|
||||
//! reply bytes, without inspecting the session's internal representation.
|
||||
//!
|
||||
//! Payload lengths here are derived from `irtt-proto`'s own layout rather than
|
||||
//! written as offsets, and are deliberately not multiples of the pattern length.
|
||||
|
||||
use irtt_proto::{echo_header_len, Clock, Params, ReceivedStats, ServerFill, StampAt};
|
||||
|
||||
use super::support::{
|
||||
client_params, core_for, core_with_tokens, echo_params, echo_request, expect_echo_reply,
|
||||
expect_no_test_reply, expect_normal_open_reply, no_test_request, open_negotiated, open_request,
|
||||
other_peer, peer, unthrottled, ScriptedTokens, KEY,
|
||||
};
|
||||
|
||||
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||
const TOKEN_B: u64 = 0x1112_1314_1516_1718;
|
||||
|
||||
/// Request payload bytes an echo carries, so a reply reflecting them would be
|
||||
/// obvious.
|
||||
const REQUEST_FILLER: u8 = 0xa5;
|
||||
|
||||
/// Params requesting `descriptor` as the server fill.
|
||||
fn requesting(descriptor: Option<&str>) -> Params {
|
||||
Params {
|
||||
server_fill: descriptor.map(|value| ServerFill {
|
||||
value: value.to_owned(),
|
||||
}),
|
||||
..client_params()
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a session requesting `descriptor` and returns the descriptor the reply
|
||||
/// carried.
|
||||
fn negotiated(descriptor: Option<&str>) -> Option<String> {
|
||||
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &open_request(&requesting(descriptor), None))
|
||||
.unwrap()
|
||||
.expect("the open must be answered");
|
||||
|
||||
let reply = expect_normal_open_reply(&packet, None);
|
||||
reply.params.server_fill.map(|fill| fill.value)
|
||||
}
|
||||
|
||||
/// The descriptor a no-test open reports for `descriptor`, which creates no
|
||||
/// session and so retains no fill.
|
||||
fn no_test_negotiated(descriptor: Option<&str>) -> Option<String> {
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let mut core = core_with_tokens(unthrottled(), tokens.clone());
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &no_test_request(&requesting(descriptor), None))
|
||||
.unwrap()
|
||||
.expect("the no-test open must be answered");
|
||||
|
||||
let reply = expect_no_test_reply(&packet, None);
|
||||
assert_eq!(core.session_count(), 0, "no-test creates no session");
|
||||
assert_eq!(tokens.remaining(), 1, "no-test draws no session token");
|
||||
reply.params.server_fill.map(|fill| fill.value)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicitly_empty_descriptor_is_preserved_and_uses_the_default_fill() {
|
||||
// A low-level peer can send this even though `ClientConfig` rejects an
|
||||
// empty fill before it reaches the wire. The clean evidence groups empty
|
||||
// with absent as "no preference", so it is neither refused nor rewritten:
|
||||
// the requested wire value comes back exactly, and the server picks its own
|
||||
// behavior behind it.
|
||||
let returned = negotiated(Some(""));
|
||||
assert_eq!(
|
||||
returned.as_deref(),
|
||||
Some(""),
|
||||
"an empty request stays empty"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_or_malformed_descriptor_is_replaced_by_the_default() {
|
||||
// Here the server really did change what was asked for, so the reply says
|
||||
// so and a strict client may refuse the session. The requested string is
|
||||
// not retained anywhere.
|
||||
for descriptor in [
|
||||
"bogus",
|
||||
"RAND",
|
||||
"None",
|
||||
"Pattern:aabb",
|
||||
"pattern:",
|
||||
"pattern:f",
|
||||
"pattern:abc",
|
||||
"pattern:zz",
|
||||
"pattern:0g",
|
||||
"0123456789abcdef0123456789abcdef",
|
||||
] {
|
||||
let returned = negotiated(Some(descriptor));
|
||||
assert_eq!(
|
||||
returned.as_deref(),
|
||||
Some(crate::fill::DEFAULT_FILL_DESCRIPTOR),
|
||||
"{descriptor}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_no_test_open_negotiates_fill_exactly_as_a_normal_open_does() {
|
||||
// Same negotiation, no session, and therefore no effective mode to retain.
|
||||
assert_eq!(no_test_negotiated(None), None);
|
||||
assert_eq!(no_test_negotiated(Some("")).as_deref(), Some(""));
|
||||
assert_eq!(
|
||||
no_test_negotiated(Some("pattern:AaBb")).as_deref(),
|
||||
Some("pattern:AaBb")
|
||||
);
|
||||
assert_eq!(no_test_negotiated(Some("none")).as_deref(), Some("none"));
|
||||
assert_eq!(
|
||||
no_test_negotiated(Some("pattern:zz")).as_deref(),
|
||||
Some(crate::fill::DEFAULT_FILL_DESCRIPTOR)
|
||||
);
|
||||
}
|
||||
|
||||
/// Params for a session requesting `descriptor` whose echo replies have exactly
|
||||
/// `payload_len` payload bytes.
|
||||
///
|
||||
/// The negotiated length is the mandatory field block plus the region wanted,
|
||||
/// asked of `irtt-proto` rather than written as a constant: the block grows with
|
||||
/// the negotiated statistics, the timestamps and authentication's 16 bytes, and
|
||||
/// a test that hard-coded an offset would silently drift from the encoder.
|
||||
fn payload_params(descriptor: Option<&str>, payload_len: usize, hmac: bool) -> Params {
|
||||
let mut params = Params {
|
||||
server_fill: requesting(descriptor).server_fill,
|
||||
..echo_params(ReceivedStats::None, StampAt::None, Clock::Unspecified, 0)
|
||||
};
|
||||
params.length = (echo_header_len(hmac, ¶ms) + payload_len) as i64;
|
||||
params
|
||||
}
|
||||
|
||||
/// The payload regions of `count` successive echo replies on one session
|
||||
/// requesting `descriptor`.
|
||||
///
|
||||
/// Every request carries [`REQUEST_FILLER`] payload bytes, so a reply that
|
||||
/// reflected its request would be visible in any of these tests rather than only
|
||||
/// in the one about it.
|
||||
fn echo_payloads(
|
||||
descriptor: Option<&str>,
|
||||
payload_len: usize,
|
||||
hmac_key: Option<&[u8]>,
|
||||
count: u32,
|
||||
) -> Vec<Vec<u8>> {
|
||||
let requested = payload_params(descriptor, payload_len, hmac_key.is_some());
|
||||
let mut core = core_for(hmac_key, ScriptedTokens::new([TOKEN_A]));
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), &requested, hmac_key);
|
||||
|
||||
// The request fills its own payload region, which is the same size as the
|
||||
// reply's: the strongest shape for "a reply never reflects its request".
|
||||
let request_payload = vec![REQUEST_FILLER; payload_len];
|
||||
(0..count)
|
||||
.map(|sequence| {
|
||||
let request = echo_request(token, sequence, &negotiated, &request_payload, hmac_key);
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &request)
|
||||
.unwrap()
|
||||
.expect("an admissible echo must be answered");
|
||||
let reply = expect_echo_reply(&packet, &negotiated, hmac_key);
|
||||
assert_eq!(
|
||||
packet.bytes().len() as i64,
|
||||
negotiated.length,
|
||||
"a reply is exactly the negotiated length"
|
||||
);
|
||||
assert_eq!(reply.payload.len(), payload_len, "payload region length");
|
||||
reply.payload
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The payload region of one echo reply on a session requesting `descriptor`.
|
||||
fn echo_payload(descriptor: Option<&str>, payload_len: usize) -> Vec<u8> {
|
||||
echo_payloads(descriptor, payload_len, None, 1).remove(0)
|
||||
}
|
||||
|
||||
/// `pattern` repeated to `len` bytes, starting at its first byte.
|
||||
fn repeated(pattern: &[u8], len: usize) -> Vec<u8> {
|
||||
pattern.iter().copied().cycle().take(len).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_preference_fills_with_the_default_pattern() {
|
||||
// An absent descriptor uses default bytes. 7 is not a multiple of 4, so
|
||||
// the last repeat is partial.
|
||||
assert_eq!(echo_payload(None, 7), b"irttirt".to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_preference_fills_with_the_default_pattern() {
|
||||
assert_eq!(echo_payload(Some(""), 7), b"irttirt".to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_or_malformed_descriptor_fills_with_the_default_pattern() {
|
||||
for descriptor in ["bogus", "RAND", "pattern:", "pattern:abc", "pattern:zz"] {
|
||||
assert_eq!(
|
||||
echo_payload(Some(descriptor), 7),
|
||||
b"irttirt".to_vec(),
|
||||
"{descriptor}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_fill_produces_a_zero_payload_and_never_the_request() {
|
||||
// `irtt-rs` policy, and a deliberate divergence: the clean evidence records
|
||||
// the reference server leaving this region as unspecified residual buffer
|
||||
// content — returning the requester's own payload, or fragments of earlier
|
||||
// traffic from another client. Zeroes disclose nothing.
|
||||
let payload = echo_payload(Some("none"), 7);
|
||||
assert_eq!(payload, vec![0; 7]);
|
||||
assert!(!payload.contains(&REQUEST_FILLER));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patterns_repeat_from_their_first_byte() {
|
||||
for (descriptor, expected) in [
|
||||
("pattern:00", &[0x00][..]),
|
||||
("pattern:ff00", &[0xff, 0x00][..]),
|
||||
// The reply follows the bytes the descriptor decodes to, whatever case
|
||||
// it was written in.
|
||||
("pattern:AaBb", &[0xaa, 0xbb][..]),
|
||||
("pattern:69727474", b"irtt"),
|
||||
] {
|
||||
assert_eq!(
|
||||
echo_payload(Some(descriptor), 7),
|
||||
repeated(expected, 7),
|
||||
"{descriptor}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pattern_restarts_for_every_reply() {
|
||||
// Deliberate `irtt-rs` policy. The clean evidence records the reference
|
||||
// implementation advancing one phase continuously across replies, sessions
|
||||
// and listeners, and states that payload phase carries no protocol meaning
|
||||
// and that a conforming client must not depend on it — so this server keeps
|
||||
// no fill state at all and every reply starts at byte zero.
|
||||
let payloads = echo_payloads(Some("pattern:010203"), 5, None, 2);
|
||||
assert_eq!(payloads[0], vec![0x01, 0x02, 0x03, 0x01, 0x02]);
|
||||
assert_eq!(
|
||||
payloads[1], payloads[0],
|
||||
"the phase resets rather than continuing at 03 01 02 03 01"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sessions_fill_independently() {
|
||||
// Interleaved on one core, so a shared cursor or a global phase would show
|
||||
// up as one session's bytes drifting into the other's.
|
||||
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A, TOKEN_B]));
|
||||
let a_requested = payload_params(Some("pattern:aabb"), 5, false);
|
||||
let b_requested = payload_params(Some("pattern:112233"), 5, false);
|
||||
let (a_token, a) = open_negotiated(&mut core, peer(), &a_requested, None);
|
||||
let (b_token, b) = open_negotiated(&mut core, other_peer(), &b_requested, None);
|
||||
|
||||
let mut payload_of = |endpoint, token, sequence, params: &Params| {
|
||||
let packet = core
|
||||
.handle_datagram(endpoint, &echo_request(token, sequence, params, &[], None))
|
||||
.unwrap()
|
||||
.expect("an admissible echo must be answered");
|
||||
expect_echo_reply(&packet, params, None).payload
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
payload_of(peer(), a_token, 0, &a),
|
||||
vec![0xaa, 0xbb, 0xaa, 0xbb, 0xaa]
|
||||
);
|
||||
assert_eq!(
|
||||
payload_of(other_peer(), b_token, 0, &b),
|
||||
vec![0x11, 0x22, 0x33, 0x11, 0x22]
|
||||
);
|
||||
assert_eq!(
|
||||
payload_of(peer(), a_token, 1, &a),
|
||||
vec![0xaa, 0xbb, 0xaa, 0xbb, 0xaa],
|
||||
"session A is unaffected by session B's reply"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_random_session_fills_the_whole_region() {
|
||||
// Deterministic assertions only: the region is the negotiated one and the
|
||||
// reply decodes and authenticates. What the operating system's random
|
||||
// source returns is not a property this suite can assert without flipping a
|
||||
// coin, and `fill`'s unit tests cover propagation and the failure fallback.
|
||||
for hmac_key in [None, Some(KEY)] {
|
||||
// `echo_payloads` already asserts the datagram length and the region
|
||||
// length of each reply, and decoding verifies the MAC. Nothing is
|
||||
// asserted about the byte values, deliberately: a random payload
|
||||
// containing any particular byte is chance, not behavior.
|
||||
assert_eq!(echo_payloads(Some("rand"), 7, hmac_key, 2).len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authentication_consumes_payload_space_at_the_same_negotiated_length() {
|
||||
// One negotiated length, two field blocks: authentication's 16 bytes come
|
||||
// out of the payload region, not out of the datagram. Every remaining
|
||||
// payload byte is still filled, and the authenticated reply's MAC covers
|
||||
// those bytes — `expect_echo_reply` verifies it while decoding.
|
||||
let unauthenticated = payload_params(Some("pattern:aa"), 48, false);
|
||||
let length = unauthenticated.length;
|
||||
let authenticated = Params {
|
||||
length,
|
||||
..payload_params(Some("pattern:aa"), 0, true)
|
||||
};
|
||||
|
||||
let plain = {
|
||||
let mut core = core_for(None, ScriptedTokens::new([TOKEN_A]));
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), &unauthenticated, None);
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.expect("an admissible echo must be answered");
|
||||
assert_eq!(packet.bytes().len() as i64, length);
|
||||
expect_echo_reply(&packet, &negotiated, None).payload
|
||||
};
|
||||
let signed = {
|
||||
let mut core = core_for(Some(KEY), ScriptedTokens::new([TOKEN_A]));
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), &authenticated, Some(KEY));
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], Some(KEY)))
|
||||
.unwrap()
|
||||
.expect("an admissible echo must be answered");
|
||||
assert_eq!(packet.bytes().len() as i64, length);
|
||||
expect_echo_reply(&packet, &negotiated, Some(KEY)).payload
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
signed.len() + irtt_proto::HMAC_SIZE,
|
||||
plain.len(),
|
||||
"authentication takes its 16 bytes from the payload region"
|
||||
);
|
||||
assert!(plain.iter().chain(&signed).all(|byte| *byte == 0xaa));
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
use irtt_proto::{verify_packet_hmac, FLAG_HMAC};
|
||||
|
||||
use super::support::{
|
||||
client_params, core_with_tokens, expect_normal_open_reply, open_request,
|
||||
open_request_with_raw_params, param_int, peer, ScriptedTokens, KEY, OTHER_KEY,
|
||||
};
|
||||
use crate::ServerConfig;
|
||||
|
||||
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||
|
||||
/// Asserts a datagram is silently discarded, leaving no trace at all: no reply,
|
||||
/// no session, and no session token drawn.
|
||||
fn assert_discarded(config: ServerConfig, name: &str, packet: &[u8]) {
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let mut core = core_with_tokens(config, tokens.clone());
|
||||
|
||||
let reply = core
|
||||
.handle_datagram(peer(), packet)
|
||||
.expect("a rejected datagram is not a server error");
|
||||
|
||||
assert!(reply.is_none(), "{name} must be answered with silence");
|
||||
assert_eq!(core.session_count(), 0, "{name} must create no session");
|
||||
assert_eq!(
|
||||
tokens.remaining(),
|
||||
1,
|
||||
"{name} must not consume session capacity or a token"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_authenticating_server_discards_everything_that_does_not_authenticate() {
|
||||
let config = ServerConfig::default().with_hmac_key(KEY);
|
||||
|
||||
assert_discarded(
|
||||
config.clone(),
|
||||
"open with no MAC field at all",
|
||||
&open_request(&client_params(), None),
|
||||
);
|
||||
assert_discarded(
|
||||
config.clone(),
|
||||
"open signed with a different key",
|
||||
&open_request(&client_params(), Some(OTHER_KEY)),
|
||||
);
|
||||
|
||||
let mut corrupted = open_request(&client_params(), Some(KEY));
|
||||
corrupted[5] ^= 0x80;
|
||||
assert_discarded(
|
||||
config.clone(),
|
||||
"open with one bit flipped in the MAC",
|
||||
&corrupted,
|
||||
);
|
||||
|
||||
let mut zeroed = open_request(&client_params(), Some(KEY));
|
||||
zeroed[4..20].fill(0);
|
||||
assert_discarded(config.clone(), "open with an all-zero MAC", &zeroed);
|
||||
|
||||
let mut truncated = open_request(&client_params(), Some(KEY));
|
||||
truncated.drain(4..12);
|
||||
assert_discarded(
|
||||
config.clone(),
|
||||
"open with a truncated MAC field",
|
||||
&truncated,
|
||||
);
|
||||
|
||||
let mut flag_cleared = open_request(&client_params(), Some(KEY));
|
||||
flag_cleared[3] &= !FLAG_HMAC;
|
||||
assert_discarded(
|
||||
config,
|
||||
"open with a valid MAC but the HMAC flag cleared",
|
||||
&flag_cleared,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_authenticating_server_discards_authenticated_requests() {
|
||||
assert_discarded(
|
||||
ServerConfig::default(),
|
||||
"authenticated open to a server with no key",
|
||||
&open_request(&client_params(), Some(KEY)),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_authenticating_server_answers_a_valid_open_with_an_authenticated_reply() {
|
||||
let mut core = core_with_tokens(
|
||||
ServerConfig::default().with_hmac_key(KEY),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
);
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &open_request(&client_params(), Some(KEY)))
|
||||
.unwrap()
|
||||
.expect("a correctly authenticated open must be answered");
|
||||
|
||||
let reply = expect_normal_open_reply(&packet, Some(KEY));
|
||||
assert_eq!(reply.token, TOKEN_A);
|
||||
assert_ne!(
|
||||
packet.bytes()[3] & FLAG_HMAC,
|
||||
0,
|
||||
"the reply must set FLAG_HMAC"
|
||||
);
|
||||
verify_packet_hmac(KEY, packet.bytes()).expect("the reply must carry a valid MAC");
|
||||
assert_eq!(core.session_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_authenticating_server_answers_an_ordinary_open() {
|
||||
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &open_request(&client_params(), None))
|
||||
.unwrap()
|
||||
.expect("an unauthenticated open must be answered");
|
||||
|
||||
expect_normal_open_reply(&packet, None);
|
||||
assert_eq!(packet.bytes()[3] & FLAG_HMAC, 0);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authentication_is_checked_before_parameters_are_decoded() {
|
||||
// Both of these are dropped, and neither reveals which stage rejected it.
|
||||
// The pairing is the point: bad parameters under a valid MAC still drop,
|
||||
// and a bad MAC drops without the parameters ever being parsed.
|
||||
let config = ServerConfig::default().with_hmac_key(KEY);
|
||||
let bad_params = param_int(2, 0);
|
||||
|
||||
assert_discarded(
|
||||
config.clone(),
|
||||
"authenticated open with an explicit zero duration",
|
||||
&open_request_with_raw_params(&bad_params, Some(KEY)),
|
||||
);
|
||||
assert_discarded(
|
||||
config,
|
||||
"wrongly keyed open with an explicit zero duration",
|
||||
&open_request_with_raw_params(&bad_params, Some(OTHER_KEY)),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_authentication_leaves_a_live_session_untouched() {
|
||||
let mut core = core_with_tokens(
|
||||
ServerConfig::default().with_hmac_key(KEY),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
);
|
||||
core.handle_datagram(peer(), &open_request(&client_params(), Some(KEY)))
|
||||
.unwrap()
|
||||
.expect("the session-creating open must succeed");
|
||||
let session = core.session(TOKEN_A).cloned().expect("session must exist");
|
||||
|
||||
for bad in [
|
||||
open_request(&client_params(), None),
|
||||
open_request(&client_params(), Some(OTHER_KEY)),
|
||||
] {
|
||||
assert_eq!(core.handle_datagram(peer(), &bad).unwrap(), None);
|
||||
}
|
||||
|
||||
assert_eq!(core.session_count(), 1);
|
||||
assert_eq!(core.session(TOKEN_A), Some(&session));
|
||||
}
|
||||
@@ -1,668 +0,0 @@
|
||||
//! Consuming a kernel-observed receive timestamp.
|
||||
//!
|
||||
//! A Linux listener reports the kernel's arrival time for each datagram, and an
|
||||
//! echo reply may report it as the wall-clock instant the request was received.
|
||||
//! These tests pin what that is allowed to change and — mostly — what it is
|
||||
//! not: only `recv_wall`, only under [`StampAt::Receive`] and [`StampAt::Both`],
|
||||
//! only on a clock that reports wall time, and never a monotonic value, a
|
||||
//! midpoint, or any lifecycle, rate or deadline decision.
|
||||
//!
|
||||
//! Whether the transport actually captures a timestamp is settled by the socket
|
||||
//! layer's own tests; nothing here recertifies `SCM_TIMESTAMPNS`. These drive
|
||||
//! the core with fabricated values, so they run identically on every target.
|
||||
|
||||
use std::{
|
||||
net::SocketAddr,
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use irtt_proto::{Clock, Params, ReceivedStats, StampAt, TimestampFields};
|
||||
|
||||
use super::support::{
|
||||
close_request, core_with_sources, echo_at, echo_params, echo_request,
|
||||
expect_closing_echo_reply, expect_echo_reply, expect_normal_open_reply, manual_core,
|
||||
open_negotiated, open_request, peer, sample, ManualClock, ScriptedClock, ScriptedTokens,
|
||||
};
|
||||
use crate::{
|
||||
clock::ClockSample,
|
||||
core::{preferred_receive_wall_ns, OutboundDatagram, ServerCore, MAX_KERNEL_RX_LAG},
|
||||
ServerConfig,
|
||||
};
|
||||
|
||||
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||
const MS: i64 = 1_000_000;
|
||||
const SECOND: i64 = 1_000 * MS;
|
||||
|
||||
/// The lifecycle instant the session-creating open consumes.
|
||||
fn open_sample() -> ClockSample {
|
||||
sample(900_000, 9_000)
|
||||
}
|
||||
|
||||
/// The core's own receive reading for the echo every matrix test sends.
|
||||
fn received() -> ClockSample {
|
||||
sample(1_000_000, 10_000)
|
||||
}
|
||||
|
||||
/// The core's send reading for the same echo.
|
||||
fn sent() -> ClockSample {
|
||||
sample(1_200_000, 10_300)
|
||||
}
|
||||
|
||||
/// A kernel arrival reading 100 µs before [`received`], which is the ordinary
|
||||
/// shape: earlier than the server's own sample and well inside the bound.
|
||||
fn kernel_rx() -> SystemTime {
|
||||
at_epoch_ns(900_000)
|
||||
}
|
||||
|
||||
/// The [`SystemTime`] `ns` nanoseconds from the Unix epoch, before it when
|
||||
/// negative.
|
||||
fn at_epoch_ns(ns: i64) -> SystemTime {
|
||||
let magnitude = Duration::from_nanos(ns.unsigned_abs());
|
||||
if ns.is_negative() {
|
||||
UNIX_EPOCH - magnitude
|
||||
} else {
|
||||
UNIX_EPOCH + magnitude
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends one echo carrying `kernel_rx_timestamp` through the runtime's entry
|
||||
/// point and returns the reply's timestamp fields.
|
||||
fn echo_timestamps(
|
||||
stamp_at: StampAt,
|
||||
clock: Clock,
|
||||
kernel_rx_timestamp: Option<SystemTime>,
|
||||
) -> TimestampFields {
|
||||
let mut core = core_with_sources(
|
||||
ServerConfig::default(),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
ScriptedClock::new([open_sample(), received(), sent()]),
|
||||
);
|
||||
let params = echo_params(ReceivedStats::None, stamp_at, clock, /* length */ 0);
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), ¶ms, None);
|
||||
|
||||
let packet = core
|
||||
.handle_received_datagram(
|
||||
peer(),
|
||||
&echo_request(token, 0, &negotiated, &[], None),
|
||||
kernel_rx_timestamp,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("{stamp_at:?} on {clock:?} must be answered"));
|
||||
expect_echo_reply(&packet, &negotiated, None).timestamps
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_preferred_receive_wall_is_the_kernel_reading_only_when_it_is_plausible() {
|
||||
let userspace = received();
|
||||
let userspace_wall = userspace.wall_ns;
|
||||
let bound_ns = i64::try_from(MAX_KERNEL_RX_LAG.as_nanos()).unwrap();
|
||||
|
||||
// No observation at all: the server's own reading, which is the whole of
|
||||
// the pre-existing behavior and what every non-Linux target sees.
|
||||
assert_eq!(preferred_receive_wall_ns(None, userspace), userspace_wall);
|
||||
|
||||
// Earlier, and inside the bound.
|
||||
assert_eq!(
|
||||
preferred_receive_wall_ns(Some(kernel_rx()), userspace),
|
||||
900_000
|
||||
);
|
||||
|
||||
// Equal is accepted: the two clocks may simply have read the same
|
||||
// nanosecond.
|
||||
assert_eq!(
|
||||
preferred_receive_wall_ns(Some(at_epoch_ns(userspace_wall)), userspace),
|
||||
userspace_wall
|
||||
);
|
||||
|
||||
// Later than the sample that observed the same datagram is impossible, so
|
||||
// it is metadata this server will not measure against. This is the shape a
|
||||
// backward wall step between the kernel's reading and the core's sample
|
||||
// produces.
|
||||
assert_eq!(
|
||||
preferred_receive_wall_ns(Some(at_epoch_ns(userspace_wall + 1)), userspace),
|
||||
userspace_wall
|
||||
);
|
||||
|
||||
// The lag bound is inclusive.
|
||||
assert_eq!(
|
||||
preferred_receive_wall_ns(Some(at_epoch_ns(userspace_wall - bound_ns)), userspace),
|
||||
userspace_wall - bound_ns
|
||||
);
|
||||
assert_eq!(
|
||||
preferred_receive_wall_ns(Some(at_epoch_ns(userspace_wall - bound_ns - 1)), userspace),
|
||||
userspace_wall
|
||||
);
|
||||
|
||||
// A pre-epoch reading is representable as a negative value and is judged
|
||||
// by exactly the same rule, so against a host clock set anywhere near the
|
||||
// present it simply lags far too much. Nothing here special-cases the sign.
|
||||
let present = sample(1_700_000_000 * SECOND, 10_000);
|
||||
assert_eq!(
|
||||
preferred_receive_wall_ns(Some(UNIX_EPOCH - Duration::from_nanos(1)), present),
|
||||
present.wall_ns
|
||||
);
|
||||
assert_eq!(
|
||||
preferred_receive_wall_ns(Some(at_epoch_ns(present.wall_ns - bound_ns)), present),
|
||||
present.wall_ns - bound_ns,
|
||||
"and a realistic wall clock accepts a reading at the bound like any other"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unrepresentable_kernel_reading_falls_back_without_panicking() {
|
||||
// Far outside the wire's signed-nanosecond field in both directions, and
|
||||
// judged against a userspace sample at the field's own limits, so nothing
|
||||
// here can be rescued by a lucky comparison. A conversion that saturated
|
||||
// instead of reporting failure would turn one of these into an ordinary
|
||||
// instant.
|
||||
let beyond = Duration::from_nanos(u64::MAX) + Duration::from_secs(1);
|
||||
|
||||
for userspace_wall in [0, i64::MAX, i64::MIN] {
|
||||
let userspace = sample(userspace_wall, 10_000);
|
||||
for kernel in [UNIX_EPOCH + beyond, UNIX_EPOCH - beyond] {
|
||||
assert_eq!(
|
||||
preferred_receive_wall_ns(Some(kernel), userspace),
|
||||
userspace_wall,
|
||||
"an unrepresentable reading is never a measurement endpoint"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_receive_wall_field_can_come_from_the_kernel() {
|
||||
// The whole StampAt x Clock matrix, each asserted as one struct so that a
|
||||
// field appearing where it should not is as loud as a wrong instant. Every
|
||||
// expectation below is what the same session emits with no kernel metadata,
|
||||
// except `recv_wall` under Receive and Both on a wall-reporting clock.
|
||||
for (stamp_at, clock, expected) in [
|
||||
// A: the kernel reading is the reported arrival time.
|
||||
(
|
||||
StampAt::Receive,
|
||||
Clock::Wall,
|
||||
TimestampFields {
|
||||
recv_wall: Some(900_000),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
// B: and the monotonic half of the same arrival stays the core's own.
|
||||
// The two fields deliberately name different physical instants.
|
||||
(
|
||||
StampAt::Receive,
|
||||
Clock::Both,
|
||||
TimestampFields {
|
||||
recv_wall: Some(900_000),
|
||||
recv_mono: Some(10_000),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
// C: nothing about the departure side moves, in either domain.
|
||||
(
|
||||
StampAt::Both,
|
||||
Clock::Both,
|
||||
TimestampFields {
|
||||
recv_wall: Some(900_000),
|
||||
recv_mono: Some(10_000),
|
||||
send_wall: Some(1_200_000),
|
||||
send_mono: Some(10_300),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
// D: wall only. The interval between these two now includes the
|
||||
// kernel-to-userspace part of the server's residency, which is the one
|
||||
// intended behavior change.
|
||||
(
|
||||
StampAt::Both,
|
||||
Clock::Wall,
|
||||
TimestampFields {
|
||||
recv_wall: Some(900_000),
|
||||
send_wall: Some(1_200_000),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
// E: no wall field exists, so there is nothing to improve.
|
||||
(
|
||||
StampAt::Both,
|
||||
Clock::Monotonic,
|
||||
TimestampFields {
|
||||
recv_mono: Some(10_000),
|
||||
send_mono: Some(10_300),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Receive,
|
||||
Clock::Monotonic,
|
||||
TimestampFields {
|
||||
recv_mono: Some(10_000),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
// F: a reply reporting only its departure never learns of an arrival
|
||||
// observation.
|
||||
(
|
||||
StampAt::Send,
|
||||
Clock::Wall,
|
||||
TimestampFields {
|
||||
send_wall: Some(1_200_000),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Send,
|
||||
Clock::Both,
|
||||
TimestampFields {
|
||||
send_wall: Some(1_200_000),
|
||||
send_mono: Some(10_300),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
// G: no timestamp at all.
|
||||
(StampAt::None, Clock::Both, TimestampFields::default()),
|
||||
// H and I: a midpoint is the mean of the core's own paired samples and
|
||||
// stays exactly that. Substituting only the wall arrival endpoint would
|
||||
// pull `midpoint_wall` to 1_050_000 — half the kernel-to-userspace
|
||||
// delay early — improving apparent upstream delay by precisely as much
|
||||
// as it worsened apparent downstream delay, and leaving the two
|
||||
// midpoint fields describing different midpoints.
|
||||
(
|
||||
StampAt::Midpoint,
|
||||
Clock::Wall,
|
||||
TimestampFields {
|
||||
midpoint_wall: Some(1_100_000),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Midpoint,
|
||||
Clock::Both,
|
||||
TimestampFields {
|
||||
midpoint_wall: Some(1_100_000),
|
||||
midpoint_mono: Some(10_150),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Midpoint,
|
||||
Clock::Monotonic,
|
||||
TimestampFields {
|
||||
midpoint_mono: Some(10_150),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
echo_timestamps(stamp_at, clock, Some(kernel_rx())),
|
||||
expected,
|
||||
"{stamp_at:?} on {clock:?} with a plausible kernel reading"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_datagram_with_no_kernel_reading_emits_what_it_always_did() {
|
||||
// The same matrix without metadata, which is every non-Linux target and
|
||||
// every Linux listener whose kernel offered nothing. `recv_wall` is the
|
||||
// core's own 1_000_000 throughout, and the two paths are otherwise the same
|
||||
// implementation.
|
||||
for (stamp_at, clock, expected) in [
|
||||
(
|
||||
StampAt::Receive,
|
||||
Clock::Wall,
|
||||
TimestampFields {
|
||||
recv_wall: Some(1_000_000),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Both,
|
||||
Clock::Both,
|
||||
TimestampFields {
|
||||
recv_wall: Some(1_000_000),
|
||||
recv_mono: Some(10_000),
|
||||
send_wall: Some(1_200_000),
|
||||
send_mono: Some(10_300),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Midpoint,
|
||||
Clock::Both,
|
||||
TimestampFields {
|
||||
midpoint_wall: Some(1_100_000),
|
||||
midpoint_mono: Some(10_150),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
echo_timestamps(stamp_at, clock, None),
|
||||
expected,
|
||||
"{stamp_at:?} on {clock:?} without kernel metadata"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_implausible_kernel_reading_falls_back_to_the_servers_own_sample() {
|
||||
// Case A of the wall-step analysis: the wall clock stepped *backwards*
|
||||
// between the kernel's reading and the core's sample, so the kernel value
|
||||
// is later than a sample that observed the same datagram. Case B: it
|
||||
// stepped forwards so far that the reading lags beyond the bound. Neither
|
||||
// is a reason to drop the datagram or to answer differently in any other
|
||||
// respect.
|
||||
let userspace_wall = received().wall_ns;
|
||||
for kernel in [
|
||||
at_epoch_ns(userspace_wall + 1),
|
||||
at_epoch_ns(userspace_wall - i64::try_from(MAX_KERNEL_RX_LAG.as_nanos()).unwrap() - 1),
|
||||
] {
|
||||
assert_eq!(
|
||||
echo_timestamps(StampAt::Both, Clock::Both, Some(kernel)),
|
||||
TimestampFields {
|
||||
recv_wall: Some(1_000_000),
|
||||
recv_mono: Some(10_000),
|
||||
send_wall: Some(1_200_000),
|
||||
send_mono: Some(10_300),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_backward_wall_step_after_receive_never_reports_receive_after_send() {
|
||||
// The kernel's reading and the send sample no longer come from one paired
|
||||
// source, so the ordering rule has to reach the reported arrival value on
|
||||
// its own: an accepted kernel reading of 900_000, against a send sample the
|
||||
// wall clock stepped back to 850_000, must still settle on the send
|
||||
// reading.
|
||||
let mut core = core_with_sources(
|
||||
ServerConfig::default(),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
ScriptedClock::new([
|
||||
open_sample(),
|
||||
sample(1_000_000, 10_000),
|
||||
sample(850_000, 10_300),
|
||||
]),
|
||||
);
|
||||
let params = echo_params(ReceivedStats::None, StampAt::Both, Clock::Both, 0);
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), ¶ms, None);
|
||||
|
||||
let packet = core
|
||||
.handle_received_datagram(
|
||||
peer(),
|
||||
&echo_request(token, 0, &negotiated, &[], None),
|
||||
Some(at_epoch_ns(900_000)),
|
||||
)
|
||||
.unwrap()
|
||||
.expect("a backward wall step must not stop the echo being answered");
|
||||
let timestamps = expect_echo_reply(&packet, &negotiated, None).timestamps;
|
||||
|
||||
assert_eq!(
|
||||
timestamps,
|
||||
TimestampFields {
|
||||
recv_wall: Some(850_000),
|
||||
recv_mono: Some(10_000),
|
||||
send_wall: Some(850_000),
|
||||
send_mono: Some(10_300),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
"the reported arrival wall value is held back to the send reading"
|
||||
);
|
||||
assert!(timestamps.recv_wall <= timestamps.send_wall);
|
||||
// The monotonic pair is ordered by its own source and owes nothing to what
|
||||
// the wall clock did.
|
||||
assert_eq!(timestamps.recv_mono, Some(10_000));
|
||||
assert!(timestamps.recv_mono <= timestamps.send_mono);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_public_core_path_emits_exactly_the_userspace_receive_wall() {
|
||||
// A direct `ServerCore` user has no transport metadata and must see the
|
||||
// behavior it always did, from the same implementation the runtime path
|
||||
// uses.
|
||||
let mut core = core_with_sources(
|
||||
ServerConfig::default(),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
ScriptedClock::new([open_sample(), received(), sent()]),
|
||||
);
|
||||
let params = echo_params(ReceivedStats::None, StampAt::Both, Clock::Both, 0);
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), ¶ms, None);
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.expect("the public path must answer an ordinary echo");
|
||||
|
||||
assert_eq!(
|
||||
expect_echo_reply(&packet, &negotiated, None).timestamps,
|
||||
TimestampFields {
|
||||
recv_wall: Some(1_000_000),
|
||||
recv_mono: Some(10_000),
|
||||
send_wall: Some(1_200_000),
|
||||
send_mono: Some(10_300),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// One echo's outcome, in the terms a lifecycle test cares about: whether it was
|
||||
/// answered at all, what it counted, and how much session state the server still
|
||||
/// holds afterwards.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct Outcome {
|
||||
answered: bool,
|
||||
closing: bool,
|
||||
recv_count: Option<u32>,
|
||||
recv_window: Option<u64>,
|
||||
session_count: usize,
|
||||
recv_wall: Option<i64>,
|
||||
}
|
||||
|
||||
fn outcome_of(
|
||||
core: &ServerCore,
|
||||
reply: Option<OutboundDatagram>,
|
||||
params: &Params,
|
||||
closing: bool,
|
||||
) -> Outcome {
|
||||
let Some(reply) = reply else {
|
||||
return Outcome {
|
||||
answered: false,
|
||||
closing: false,
|
||||
recv_count: None,
|
||||
recv_window: None,
|
||||
session_count: core.session_count(),
|
||||
recv_wall: None,
|
||||
};
|
||||
};
|
||||
let decoded = if closing {
|
||||
expect_closing_echo_reply(&reply, params, None)
|
||||
} else {
|
||||
expect_echo_reply(&reply, params, None)
|
||||
};
|
||||
Outcome {
|
||||
answered: true,
|
||||
closing,
|
||||
recv_count: decoded.recv_count,
|
||||
recv_window: decoded.recv_window,
|
||||
session_count: core.session_count(),
|
||||
recv_wall: decoded.timestamps.recv_wall,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends one echo as it would arrive at `at_ns`, with the kernel having observed
|
||||
/// it 1 µs earlier when `with_kernel_rx` is set.
|
||||
fn echo_at_with_kernel_rx(
|
||||
core: &mut ServerCore,
|
||||
clock: &ManualClock,
|
||||
at_ns: i64,
|
||||
token: u64,
|
||||
sequence: u32,
|
||||
params: &Params,
|
||||
with_kernel_rx: bool,
|
||||
) -> Option<OutboundDatagram> {
|
||||
if !with_kernel_rx {
|
||||
return echo_at(core, clock, at_ns, token, sequence, params, None);
|
||||
}
|
||||
clock.set(at_ns);
|
||||
core.handle_received_datagram(
|
||||
peer(),
|
||||
&echo_request(token, sequence, params, &[], None),
|
||||
Some(at_epoch_ns(at_ns - 1_000)),
|
||||
)
|
||||
.expect("a rejected or rate-limited echo is not an internal error")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kernel_metadata_changes_no_lifecycle_rate_or_deadline_decision() {
|
||||
// Two identical runs over one scripted arrival schedule, one with a
|
||||
// plausible kernel reading on every echo and one with none. The schedule
|
||||
// crosses a rate-limit burst, a refill, an idle refresh that only a
|
||||
// rate-limited request can have produced, and the maximum-duration
|
||||
// boundary — and the two runs must agree on every one of those outcomes.
|
||||
//
|
||||
// The session reports its receive wall, so the one permitted difference is
|
||||
// visible rather than merely assumed absent.
|
||||
let params = Params {
|
||||
interval_ns: 100 * MS,
|
||||
..echo_params(ReceivedStats::Both, StampAt::Receive, Clock::Wall, 0)
|
||||
};
|
||||
let config = ServerConfig::default()
|
||||
.with_min_send_interval(Duration::from_millis(100))
|
||||
.with_burst_allowance(2)
|
||||
.with_idle_timeout(Duration::from_secs(2))
|
||||
.with_max_test_duration(Duration::from_secs(1));
|
||||
|
||||
let run = |with_kernel_rx: bool| {
|
||||
let (mut core, clock, token, negotiated) = manual_core(
|
||||
config.clone(),
|
||||
¶ms,
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
None,
|
||||
);
|
||||
let mut outcomes = Vec::new();
|
||||
let mut send = |core: &mut ServerCore, at_ns: i64, sequence: u32, closing: bool| {
|
||||
let reply = echo_at_with_kernel_rx(
|
||||
core,
|
||||
&clock,
|
||||
at_ns,
|
||||
token,
|
||||
sequence,
|
||||
&negotiated,
|
||||
with_kernel_rx,
|
||||
);
|
||||
outcomes.push(outcome_of(core, reply, &negotiated, closing));
|
||||
};
|
||||
|
||||
// A burst of three against an allowance of two: the third is
|
||||
// rate-limited, answers nothing and advances no statistic, but does
|
||||
// refresh the idle deadline.
|
||||
send(&mut core, 10 * MS, 0, false);
|
||||
send(&mut core, 10 * MS, 1, false);
|
||||
send(&mut core, 10 * MS, 2, false);
|
||||
// Past the refill, and out of order, so the window transition is
|
||||
// exercised too.
|
||||
send(&mut core, 200 * MS, 4, false);
|
||||
send(&mut core, 400 * MS, 3, false);
|
||||
// The idle timeout is 2 s and the last activity was at 400 ms, so this
|
||||
// request at 2.3 s finds the session alive only because activity — not
|
||||
// the open — is what the deadline runs from.
|
||||
send(&mut core, 2_300 * MS, 5, false);
|
||||
// The maximum-duration deadline is the first served echo plus one
|
||||
// second plus the two-second grace: 10 ms + 3 s. This crosses it and
|
||||
// carries the close, releasing the session.
|
||||
send(&mut core, 3_100 * MS, 6, true);
|
||||
// And every later request is an unknown token.
|
||||
send(&mut core, 3_200 * MS, 7, false);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
outcomes
|
||||
};
|
||||
|
||||
let without = run(false);
|
||||
let with = run(true);
|
||||
|
||||
assert_eq!(
|
||||
without.len(),
|
||||
8,
|
||||
"the schedule must actually have run to the end"
|
||||
);
|
||||
for (index, (without, with)) in without.iter().zip(&with).enumerate() {
|
||||
assert_eq!(
|
||||
(
|
||||
with.answered,
|
||||
with.closing,
|
||||
with.recv_count,
|
||||
with.recv_window,
|
||||
with.session_count
|
||||
),
|
||||
(
|
||||
without.answered,
|
||||
without.closing,
|
||||
without.recv_count,
|
||||
without.recv_window,
|
||||
without.session_count
|
||||
),
|
||||
"echo {index}: kernel metadata may not change any same-host decision"
|
||||
);
|
||||
}
|
||||
|
||||
// The permitted difference, and proof the runs were not simply identical:
|
||||
// every answered reply reported the kernel's reading rather than the
|
||||
// server's own, which is 1 µs later.
|
||||
let reported: Vec<_> = with
|
||||
.iter()
|
||||
.filter_map(|outcome| outcome.recv_wall)
|
||||
.collect();
|
||||
let userspace: Vec<_> = without
|
||||
.iter()
|
||||
.filter_map(|outcome| outcome.recv_wall)
|
||||
.collect();
|
||||
assert_eq!(reported.len(), 6, "six of the eight echoes were answered");
|
||||
assert_eq!(
|
||||
reported,
|
||||
userspace
|
||||
.iter()
|
||||
.map(|wall| wall - 1_000)
|
||||
.collect::<Vec<_>>(),
|
||||
"each answered reply reported the kernel's arrival reading"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_open_or_close_ignores_kernel_metadata_entirely() {
|
||||
// Neither emits a receive timestamp, and neither may take a different
|
||||
// lifecycle decision for having been observed. The clock script is what
|
||||
// pins that: an open consumes exactly its own lifecycle sample whatever the
|
||||
// transport reported.
|
||||
let clock = ScriptedClock::new([open_sample(), sample(1_000_000, 10_000)]);
|
||||
let mut core = core_with_sources(
|
||||
ServerConfig::default(),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
clock.clone(),
|
||||
);
|
||||
let params = echo_params(ReceivedStats::None, StampAt::Both, Clock::Both, 0);
|
||||
|
||||
let packet = core
|
||||
.handle_received_datagram(peer(), &open_request(¶ms, None), Some(kernel_rx()))
|
||||
.unwrap()
|
||||
.expect("an open must be answered");
|
||||
let reply = expect_normal_open_reply(&packet, None);
|
||||
assert_eq!(
|
||||
clock.remaining(),
|
||||
1,
|
||||
"an open consumes one lifecycle sample"
|
||||
);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
|
||||
let closed: SocketAddr = peer();
|
||||
assert!(core
|
||||
.handle_received_datagram(closed, &close_request(reply.token, None), Some(kernel_rx()))
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert_eq!(core.session_count(), 0, "the close released its session");
|
||||
assert_eq!(
|
||||
clock.remaining(),
|
||||
0,
|
||||
"and a close consumes one of its own, whatever the transport observed"
|
||||
);
|
||||
}
|
||||
@@ -1,621 +0,0 @@
|
||||
//! Session lifetime: idle expiry and the maximum-duration close.
|
||||
//!
|
||||
//! Time is a hand-moved monotonic clock, so every deadline here is exact and no
|
||||
//! test sleeps, tolerates or races.
|
||||
//!
|
||||
//! Two of the behaviors pinned here are deliberate `irtt-rs` policy rather than
|
||||
//! observed reference behavior, and each says so where it is tested: expiry is
|
||||
//! immediate at the configured deadline with no reply of any kind, and the
|
||||
//! deadline covers a session that has never carried an echo request. The clean
|
||||
//! specification records the reference server's five-second grace, its final
|
||||
//! lazy-release reply and its never-expiring unused sessions as upstream policy,
|
||||
//! states that expiry is entirely a server's own business, and recommends
|
||||
//! against reproducing the resource leak. The one interoperability constraint is
|
||||
//! negative — a server must never *signal* expiry — and silence satisfies it.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use irtt_proto::{echo_header_len, Clock, Params, ReceivedStats, ServerFill, StampAt, FLAG_CLOSE};
|
||||
|
||||
use super::support::{
|
||||
close_request, core_with_sources, echo_at, echo_params, echo_request,
|
||||
expect_closing_echo_reply, expect_echo_reply, expect_no_test_reply, manual_core,
|
||||
no_test_request, open_request, other_peer, peer, ManualClock, ScriptedTokens, KEY,
|
||||
};
|
||||
use crate::{OutboundDatagram, ServerConfig};
|
||||
|
||||
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||
const TOKEN_B: u64 = 0x1112_1314_1516_1718;
|
||||
|
||||
const MS: i64 = 1_000_000;
|
||||
const SECOND: i64 = 1_000_000_000;
|
||||
|
||||
/// Params reporting both statistics and requesting `interval_ns`.
|
||||
fn params_at(interval_ns: i64) -> Params {
|
||||
Params {
|
||||
interval_ns,
|
||||
..echo_params(
|
||||
ReceivedStats::Both,
|
||||
StampAt::None,
|
||||
Clock::Unspecified,
|
||||
/* length */ 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A configuration that expires after `idle` and rate-limits nothing.
|
||||
fn idle_after(idle: Duration) -> ServerConfig {
|
||||
ServerConfig::default()
|
||||
.with_idle_timeout(idle)
|
||||
.with_min_send_interval(Duration::ZERO)
|
||||
}
|
||||
|
||||
fn count_of(reply: Option<OutboundDatagram>, params: &Params) -> Option<u32> {
|
||||
let reply = reply?;
|
||||
expect_echo_reply(reply, params, None).recv_count
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_that_never_carried_an_echo_still_expires() {
|
||||
// `irtt-rs` policy. The reference server starts the idle clock at the first
|
||||
// echo request, so a session left unused never expires at all; a session is
|
||||
// created by one unauthenticated datagram and opens are never deduplicated,
|
||||
// which makes that an unbounded-state hazard rather than a compatibility
|
||||
// feature. Here the deadline runs from the open.
|
||||
//
|
||||
// A one-session table makes the reclamation externally visible: the second
|
||||
// open can only succeed if the first session was released before capacity
|
||||
// was judged.
|
||||
let config = idle_after(Duration::from_secs(2)).with_max_sessions(1);
|
||||
let clock = ManualClock::at(0);
|
||||
let mut core = core_with_sources(
|
||||
config,
|
||||
ScriptedTokens::new([TOKEN_A, TOKEN_B]),
|
||||
clock.clone(),
|
||||
);
|
||||
let requested = params_at(SECOND);
|
||||
|
||||
clock.set(0);
|
||||
assert!(core
|
||||
.handle_datagram(peer(), &open_request(&requested, None))
|
||||
.unwrap()
|
||||
.is_some());
|
||||
assert_eq!(core.session_count(), 1);
|
||||
|
||||
// Still inside the deadline: the table is full and a second open is refused.
|
||||
clock.set(2 * SECOND - 1);
|
||||
assert_eq!(
|
||||
core.handle_datagram(other_peer(), &open_request(&requested, None))
|
||||
.unwrap(),
|
||||
None,
|
||||
"a live session must still occupy the table"
|
||||
);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
|
||||
// At the deadline the unused session is reclaimed, before capacity is
|
||||
// judged, so the new open fits.
|
||||
clock.set(2 * SECOND);
|
||||
assert!(
|
||||
core.handle_datagram(other_peer(), &open_request(&requested, None))
|
||||
.unwrap()
|
||||
.is_some(),
|
||||
"an expired session must not deny capacity to a new one"
|
||||
);
|
||||
assert_eq!(core.session_count(), 1, "one replaced the other");
|
||||
|
||||
// And the reclaimed token names nothing.
|
||||
assert!(
|
||||
echo_at(&mut core, &clock, 2 * SECOND, TOKEN_A, 0, &requested, None).is_none(),
|
||||
"the released token must be unusable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maintenance_reclaims_an_idle_session_without_traffic() {
|
||||
let clock = ManualClock::at(0);
|
||||
let mut core = core_with_sources(
|
||||
idle_after(Duration::from_secs(2)),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
clock.clone(),
|
||||
);
|
||||
|
||||
assert!(core
|
||||
.handle_datagram(peer(), &open_request(¶ms_at(SECOND), None))
|
||||
.unwrap()
|
||||
.is_some());
|
||||
assert_eq!(core.session_count(), 1);
|
||||
|
||||
clock.set(2 * SECOND);
|
||||
core.maintain();
|
||||
assert_eq!(core.session_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_served_echo_moves_the_idle_deadline_along() {
|
||||
let (mut core, clock, token, negotiated) = manual_core(
|
||||
idle_after(Duration::from_secs(2)),
|
||||
¶ms_at(SECOND),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
None,
|
||||
);
|
||||
|
||||
// Inside the deadline the open set, and past the one it would have had.
|
||||
assert_eq!(
|
||||
count_of(
|
||||
echo_at(&mut core, &clock, SECOND, token, 0, &negotiated, None),
|
||||
&negotiated
|
||||
),
|
||||
Some(1)
|
||||
);
|
||||
assert_eq!(
|
||||
count_of(
|
||||
echo_at(&mut core, &clock, 2_900 * MS, token, 1, &negotiated, None),
|
||||
&negotiated
|
||||
),
|
||||
Some(2),
|
||||
"the echo at 1 s moved the deadline from 2 s to 3 s"
|
||||
);
|
||||
|
||||
// Exactly at the deadline the last served echo set: gone, and silently.
|
||||
assert!(
|
||||
echo_at(&mut core, &clock, 4_900 * MS, token, 2, &negotiated, None).is_none(),
|
||||
"the deadline is inclusive"
|
||||
);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_expired_session_answers_nothing_at_all() {
|
||||
// `irtt-rs` policy, and the reason this is its own test. The reference
|
||||
// server was observed answering the first otherwise-serviceable echo to
|
||||
// reach an expired session — a final lazy-release reply — and only dropping
|
||||
// the one after it, with the release boundary a further five seconds past
|
||||
// the configured timeout. `irtt-rs` reproduces neither: at the deadline the
|
||||
// session is simply gone, and the client observes silence, which is the only
|
||||
// thing the specification actually requires of expiry.
|
||||
let (mut core, clock, token, negotiated) = manual_core(
|
||||
idle_after(Duration::from_secs(2)),
|
||||
¶ms_at(SECOND),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
count_of(
|
||||
echo_at(
|
||||
&mut core,
|
||||
&clock,
|
||||
2 * SECOND - 1,
|
||||
token,
|
||||
0,
|
||||
&negotiated,
|
||||
None
|
||||
),
|
||||
&negotiated
|
||||
),
|
||||
Some(1),
|
||||
"one nanosecond short of the deadline is still served"
|
||||
);
|
||||
|
||||
// Two seconds after that echo, to the nanosecond.
|
||||
assert!(
|
||||
echo_at(
|
||||
&mut core,
|
||||
&clock,
|
||||
4 * SECOND - 1,
|
||||
token,
|
||||
1,
|
||||
&negotiated,
|
||||
None
|
||||
)
|
||||
.is_none(),
|
||||
"no final reply: an expired session is simply gone"
|
||||
);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
assert!(
|
||||
echo_at(&mut core, &clock, 4 * SECOND, token, 2, &negotiated, None).is_none(),
|
||||
"and stays gone"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rate_limited_echo_keeps_its_session_alive_without_counting() {
|
||||
// The one drop class the clean evidence records as refreshing the idle
|
||||
// deadline. It was established against every other tested class, which does
|
||||
// not, so `irtt-rs` keeps it.
|
||||
//
|
||||
// A 2 s minimum interval against an 8 s idle timeout negotiates 2 s and
|
||||
// refills every 2 s; a burst of one then makes the echo at 8.5 s — 1.5 s
|
||||
// after the previous one — rate-limited.
|
||||
let config = ServerConfig::default()
|
||||
.with_min_send_interval(Duration::from_secs(2))
|
||||
.with_idle_timeout(Duration::from_secs(8))
|
||||
.with_burst_allowance(1);
|
||||
let (mut core, clock, token, negotiated) = manual_core(
|
||||
config,
|
||||
¶ms_at(2 * SECOND),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
None,
|
||||
);
|
||||
assert_eq!(negotiated.interval_ns, 2 * SECOND);
|
||||
|
||||
// Served, spending the burst. The idle deadline is now 15 s.
|
||||
assert_eq!(
|
||||
count_of(
|
||||
echo_at(&mut core, &clock, 7 * SECOND, token, 0, &negotiated, None),
|
||||
&negotiated
|
||||
),
|
||||
Some(1)
|
||||
);
|
||||
|
||||
// Rate-limited, 1.5 s later: no reply, and the deadline moves to 16.5 s.
|
||||
assert!(
|
||||
echo_at(&mut core, &clock, 8_500 * MS, token, 1, &negotiated, None).is_none(),
|
||||
"no allowance, so no reply"
|
||||
);
|
||||
|
||||
// Past the deadline the served echo alone would have set, and short of the
|
||||
// one the rate-limited echo set.
|
||||
assert_eq!(
|
||||
count_of(
|
||||
echo_at(&mut core, &clock, 16 * SECOND, token, 2, &negotiated, None),
|
||||
&negotiated
|
||||
),
|
||||
Some(2),
|
||||
"the rate-limited echo kept the session alive, and was not counted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rejected_echo_does_not_move_the_idle_deadline() {
|
||||
// Two representative classes; the admission rules themselves are pinned by
|
||||
// the echo and hmac tests, and are not repeated here.
|
||||
let config = idle_after(Duration::from_secs(2)).with_max_packet_length(64);
|
||||
let (mut core, clock, token, negotiated) = manual_core(
|
||||
config,
|
||||
¶ms_at(SECOND),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
None,
|
||||
);
|
||||
|
||||
let oversized = echo_request(
|
||||
token,
|
||||
0,
|
||||
&Params {
|
||||
length: 65,
|
||||
..negotiated.clone()
|
||||
},
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
assert_eq!(oversized.len(), 65);
|
||||
|
||||
clock.set(2 * SECOND - 1);
|
||||
assert_eq!(core.handle_datagram(peer(), &oversized).unwrap(), None);
|
||||
assert_eq!(
|
||||
core.handle_datagram(
|
||||
other_peer(),
|
||||
&echo_request(token, 0, &negotiated, &[], None)
|
||||
)
|
||||
.unwrap(),
|
||||
None,
|
||||
"a foreign endpoint is refused whatever the session's state"
|
||||
);
|
||||
assert_eq!(core.session_count(), 1, "neither released it early either");
|
||||
|
||||
// The deadline the open set, unmoved.
|
||||
assert!(
|
||||
echo_at(&mut core, &clock, 2 * SECOND, token, 0, &negotiated, None).is_none(),
|
||||
"a rejected echo must not have extended the session"
|
||||
);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_authenticated_request_reclaims_expired_sessions_including_a_no_test_open() {
|
||||
// `irtt-rs` policy. Expiry is a decision about the table, so it runs before
|
||||
// a request is dispatched and does not depend on what that request is for.
|
||||
// The reference server was observed releasing an expired session on a burst
|
||||
// of ordinary opens but not on no-test ones; a client cannot tell, since
|
||||
// expiry is never signalled, and the distinction is not worth reproducing.
|
||||
//
|
||||
// A no-test open still creates nothing of its own, which the token source
|
||||
// and the session count both check here.
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let clock = ManualClock::at(0);
|
||||
let mut core = core_with_sources(
|
||||
idle_after(Duration::from_secs(2)),
|
||||
tokens.clone(),
|
||||
clock.clone(),
|
||||
);
|
||||
let requested = params_at(SECOND);
|
||||
|
||||
assert!(core
|
||||
.handle_datagram(peer(), &open_request(&requested, None))
|
||||
.unwrap()
|
||||
.is_some());
|
||||
assert_eq!(core.session_count(), 1);
|
||||
assert_eq!(tokens.remaining(), 0);
|
||||
|
||||
clock.set(2 * SECOND);
|
||||
let packet = core
|
||||
.handle_datagram(other_peer(), &no_test_request(&requested, None))
|
||||
.unwrap()
|
||||
.expect("a no-test open is answered whatever the table holds");
|
||||
expect_no_test_reply(&packet, None);
|
||||
|
||||
assert_eq!(
|
||||
core.session_count(),
|
||||
0,
|
||||
"the expired session was reclaimed, and no-test created none of its own"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_client_close_still_releases_its_session_without_a_reply() {
|
||||
// Unchanged by this slice, and worth pinning next to the close the *server*
|
||||
// sends: a client close is answered with nothing at all.
|
||||
let (mut core, clock, token, negotiated) = manual_core(
|
||||
idle_after(Duration::from_secs(60)),
|
||||
¶ms_at(SECOND),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
None,
|
||||
);
|
||||
assert!(echo_at(&mut core, &clock, SECOND, token, 0, &negotiated, None).is_some());
|
||||
|
||||
clock.set(2 * SECOND);
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &close_request(token, None))
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
}
|
||||
|
||||
/// A configuration whose sessions run for `maximum` and are never idle-expired
|
||||
/// within a test's timescale.
|
||||
fn max_duration_of(maximum: Duration) -> ServerConfig {
|
||||
ServerConfig::default()
|
||||
.with_max_test_duration(maximum)
|
||||
.with_idle_timeout(Duration::from_secs(600))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_maximum_duration_deadline_starts_at_the_first_served_echo() {
|
||||
// Measured behavior: opening a session well before its first echo does not
|
||||
// move the deadline, and neither a rejected nor a rate-limited first request
|
||||
// starts it.
|
||||
let (mut core, clock, token, negotiated) = manual_core(
|
||||
max_duration_of(Duration::from_secs(1)),
|
||||
¶ms_at(SECOND),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
negotiated.duration_ns, SECOND,
|
||||
"the requested duration was reduced to the maximum"
|
||||
);
|
||||
|
||||
// Ten seconds after the open — far past maximum + grace, had the open
|
||||
// started the clock.
|
||||
let packet = echo_at(&mut core, &clock, 10 * SECOND, token, 0, &negotiated, None)
|
||||
.expect("the first echo must be answered");
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
assert_eq!(reply.recv_count, Some(1));
|
||||
|
||||
// The deadline is 13 s: this echo, three seconds after the open but two
|
||||
// before the origin's own deadline, is ordinary.
|
||||
let packet = echo_at(&mut core, &clock, 12_999 * MS, token, 1, &negotiated, None)
|
||||
.expect("an echo inside the deadline must be answered");
|
||||
assert_eq!(
|
||||
expect_echo_reply(&packet, &negotiated, None).recv_count,
|
||||
Some(2)
|
||||
);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_echo_past_the_maximum_duration_carries_close_and_ends_the_session() {
|
||||
// The session negotiates a fill and a payload region, so this covers the
|
||||
// close's payload as well: a server close is an ordinary echo reply with a
|
||||
// flag added, and it fills exactly as the session's other replies do.
|
||||
let requested = Params {
|
||||
server_fill: Some(ServerFill {
|
||||
value: "pattern:aabb".to_owned(),
|
||||
}),
|
||||
length: (echo_header_len(false, ¶ms_at(SECOND)) + 5) as i64,
|
||||
..params_at(SECOND)
|
||||
};
|
||||
let patterned = vec![0xaa, 0xbb, 0xaa, 0xbb, 0xaa];
|
||||
let (mut core, clock, token, negotiated) = manual_core(
|
||||
max_duration_of(Duration::from_secs(1)),
|
||||
&requested,
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(echo_at(&mut core, &clock, 10 * SECOND, token, 0, &negotiated, None).is_some());
|
||||
// One nanosecond short of maximum + 2 s grace, measured from that echo.
|
||||
let packet = echo_at(
|
||||
&mut core,
|
||||
&clock,
|
||||
13 * SECOND - 1,
|
||||
token,
|
||||
1,
|
||||
&negotiated,
|
||||
None,
|
||||
)
|
||||
.expect("short of the deadline must be answered normally");
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
assert_eq!(reply.flags & FLAG_CLOSE, 0, "not yet");
|
||||
assert_eq!(reply.payload, patterned, "an ordinary reply's fill");
|
||||
|
||||
// At the deadline exactly.
|
||||
let packet = echo_at(&mut core, &clock, 13 * SECOND, token, 2, &negotiated, None)
|
||||
.expect("the deadline-crossing echo is answered, with Close");
|
||||
let reply = expect_closing_echo_reply(&packet, &negotiated, None);
|
||||
assert_eq!(reply.token, token, "an ordinary reply in every other way");
|
||||
assert_eq!(reply.sequence, 2, "the triggering sequence, copied through");
|
||||
assert_eq!(
|
||||
(reply.recv_count, reply.recv_window),
|
||||
(Some(3), Some(0x7)),
|
||||
"the triggering request is included in the statistics it reports"
|
||||
);
|
||||
assert_eq!(
|
||||
reply.payload, patterned,
|
||||
"the close keeps the session's fill; it is not a special packet"
|
||||
);
|
||||
assert_eq!(
|
||||
packet.bytes().len(),
|
||||
irtt_proto::echo_packet_len(false, &negotiated).unwrap(),
|
||||
"the negotiated layout, unchanged by the flag"
|
||||
);
|
||||
assert_eq!(
|
||||
i64::from(packet.traffic_class()),
|
||||
negotiated.dscp,
|
||||
"a server close is an echo reply and keeps the session's marking"
|
||||
);
|
||||
|
||||
// The token is unusable from that reply onward.
|
||||
assert_eq!(core.session_count(), 0);
|
||||
assert!(
|
||||
echo_at(
|
||||
&mut core,
|
||||
&clock,
|
||||
13 * SECOND + 1,
|
||||
token,
|
||||
3,
|
||||
&negotiated,
|
||||
None
|
||||
)
|
||||
.is_none(),
|
||||
"every later request is an unknown token"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rate_limited_echo_at_the_deadline_defers_the_close_rather_than_losing_it() {
|
||||
// The measured dropped-trigger case, and the reason rate allowance is
|
||||
// judged before the deadline: a deadline-crossing echo with no allowance is
|
||||
// silently dropped, the session stays usable, and the next echo that *is*
|
||||
// served carries the close.
|
||||
//
|
||||
// A 5 s minimum interval against a 600 s idle timeout negotiates 5 s and
|
||||
// refills every 5 s, so the echo three seconds after the first is limited.
|
||||
let config = max_duration_of(Duration::from_secs(1))
|
||||
.with_min_send_interval(Duration::from_secs(5))
|
||||
.with_burst_allowance(1);
|
||||
let (mut core, clock, token, negotiated) = manual_core(
|
||||
config,
|
||||
¶ms_at(5 * SECOND),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
None,
|
||||
);
|
||||
assert_eq!(negotiated.interval_ns, 5 * SECOND);
|
||||
|
||||
assert_eq!(
|
||||
count_of(
|
||||
echo_at(&mut core, &clock, 10 * SECOND, token, 0, &negotiated, None),
|
||||
&negotiated
|
||||
),
|
||||
Some(1),
|
||||
"the origin, which also spends the burst"
|
||||
);
|
||||
|
||||
assert!(
|
||||
echo_at(&mut core, &clock, 13 * SECOND, token, 1, &negotiated, None).is_none(),
|
||||
"past the deadline but with no allowance: dropped, not closed"
|
||||
);
|
||||
assert_eq!(core.session_count(), 1, "and the session is still usable");
|
||||
|
||||
// Two seconds later the allowance is back.
|
||||
let packet = echo_at(&mut core, &clock, 15 * SECOND, token, 2, &negotiated, None)
|
||||
.expect("the next served echo carries the close");
|
||||
let reply = expect_closing_echo_reply(&packet, &negotiated, None);
|
||||
assert_eq!(
|
||||
reply.recv_count,
|
||||
Some(2),
|
||||
"the rate-limited request was never counted"
|
||||
);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rejected_echo_past_the_deadline_neither_closes_nor_releases() {
|
||||
// The close trigger runs only after ordinary admission and rate allowance
|
||||
// succeed, so a datagram the server would refuse at any time refuses in the
|
||||
// ordinary way here too. The specification records the corresponding
|
||||
// upstream behavior as untested for classes other than rate limiting, so
|
||||
// this is the deterministic `irtt-rs` reading rather than an observed one.
|
||||
let (mut core, clock, token, negotiated) = manual_core(
|
||||
max_duration_of(Duration::from_secs(1)),
|
||||
¶ms_at(SECOND),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
None,
|
||||
);
|
||||
assert!(echo_at(&mut core, &clock, 10 * SECOND, token, 0, &negotiated, None).is_some());
|
||||
|
||||
clock.set(14 * SECOND);
|
||||
assert_eq!(
|
||||
core.handle_datagram(
|
||||
other_peer(),
|
||||
&echo_request(token, 1, &negotiated, &[], None)
|
||||
)
|
||||
.unwrap(),
|
||||
None,
|
||||
"a foreign endpoint gets silence, not a close"
|
||||
);
|
||||
assert_eq!(core.session_count(), 1, "and does not release the session");
|
||||
|
||||
let packet = echo_at(&mut core, &clock, 14_100 * MS, token, 2, &negotiated, None)
|
||||
.expect("the owning endpoint still receives the close");
|
||||
let reply = expect_closing_echo_reply(&packet, &negotiated, None);
|
||||
assert_eq!(
|
||||
reply.recv_count,
|
||||
Some(2),
|
||||
"the foreign request advanced nothing"
|
||||
);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_authenticated_session_closes_through_the_ordinary_reply_path() {
|
||||
// No special close authentication exists, and none should: the flag rides on
|
||||
// a reply the normal encoder and key path produced.
|
||||
let config = max_duration_of(Duration::from_secs(1)).with_hmac_key(KEY);
|
||||
let (mut core, clock, token, negotiated) = manual_core(
|
||||
config,
|
||||
¶ms_at(SECOND),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
Some(KEY),
|
||||
);
|
||||
|
||||
assert!(echo_at(
|
||||
&mut core,
|
||||
&clock,
|
||||
10 * SECOND,
|
||||
token,
|
||||
0,
|
||||
&negotiated,
|
||||
Some(KEY)
|
||||
)
|
||||
.is_some());
|
||||
|
||||
let packet = echo_at(
|
||||
&mut core,
|
||||
&clock,
|
||||
13 * SECOND,
|
||||
token,
|
||||
1,
|
||||
&negotiated,
|
||||
Some(KEY),
|
||||
)
|
||||
.expect("the deadline-crossing echo is answered");
|
||||
// Decoding verifies the MAC, and the flag comparison pins HMAC and Close
|
||||
// together.
|
||||
let reply = expect_closing_echo_reply(&packet, &negotiated, Some(KEY));
|
||||
assert_eq!(reply.recv_count, Some(2));
|
||||
irtt_proto::verify_packet_hmac(KEY, packet.bytes())
|
||||
.expect("the close-flagged reply is authenticated");
|
||||
assert_eq!(core.session_count(), 0);
|
||||
}
|
||||
@@ -1,29 +1,4 @@
|
||||
//! Deterministic tests for the server core.
|
||||
//!
|
||||
//! The core takes a source endpoint and datagram bytes and returns reply bytes,
|
||||
//! so these drive it directly; no socket is bound. Requests are built with the
|
||||
//! production encoders wherever the wire form is well-formed, and by hand only
|
||||
//! where the point of the test is a payload a compliant encoder cannot produce.
|
||||
//!
|
||||
//! The two nondeterministic parts are injected. Tokens come from a scripted
|
||||
//! source so session identity, collisions and allocation failure are all
|
||||
//! assertable; the clock an echo reply's timestamps come from is scripted the
|
||||
//! same way, so timestamp behavior is pinned exactly, with no sleeps, no
|
||||
//! runtime and no timing tolerances.
|
||||
//! Fault paths that cannot reasonably be induced through OS randomness.
|
||||
|
||||
mod admission;
|
||||
mod close;
|
||||
mod echo;
|
||||
mod fill;
|
||||
mod hmac;
|
||||
mod kernel_rx;
|
||||
mod lifecycle;
|
||||
mod negotiation;
|
||||
mod no_test;
|
||||
mod open;
|
||||
mod params;
|
||||
mod rate;
|
||||
mod sessions;
|
||||
mod support;
|
||||
mod tokens;
|
||||
mod traffic_class;
|
||||
@@ -1,392 +0,0 @@
|
||||
//! Parameter restrictions the server applies to an open request.
|
||||
//!
|
||||
//! Every case drives a real open through the core and reads the restriction out
|
||||
//! of the open reply, because the reply is the contract: a client enforces its
|
||||
//! own restriction policy against exactly these values, and the server has to
|
||||
//! honor what it returned.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use irtt_proto::{Clock, Params, StampAt};
|
||||
|
||||
use super::support::{
|
||||
client_params, core_with_tokens, echo_request, expect_echo_reply, expect_normal_open_reply,
|
||||
open_request, peer, unthrottled, ScriptedTokens,
|
||||
};
|
||||
use crate::{ServerConfig, TimestampAllowance};
|
||||
|
||||
const TOKEN_A: u64 = 0x2f45_9a01_c37d_6e88;
|
||||
|
||||
/// Negotiates one open against `config` and returns the restricted parameters.
|
||||
fn negotiated(config: ServerConfig, requested: &Params) -> Params {
|
||||
let mut core = core_with_tokens(config, ScriptedTokens::new([TOKEN_A]));
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &open_request(requested, None))
|
||||
.unwrap()
|
||||
.expect("the open must be answered");
|
||||
expect_normal_open_reply(&packet, None).params
|
||||
}
|
||||
|
||||
fn requesting(duration_ns: i64, interval_ns: i64) -> Params {
|
||||
Params {
|
||||
duration_ns,
|
||||
interval_ns,
|
||||
..client_params()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_requested_duration_is_reduced_to_the_configured_maximum() {
|
||||
let config = || ServerConfig::default().with_max_test_duration(Duration::from_secs(2));
|
||||
|
||||
assert_eq!(
|
||||
negotiated(config(), &requesting(10_000_000_000, 1_000_000_000)).duration_ns,
|
||||
2_000_000_000,
|
||||
"a longer duration is reduced to the maximum"
|
||||
);
|
||||
assert_eq!(
|
||||
negotiated(config(), &requesting(1_000_000_000, 1_000_000_000)).duration_ns,
|
||||
1_000_000_000,
|
||||
"a duration under the maximum is left alone"
|
||||
);
|
||||
// Duration zero is absent from the request, which means continuous. A
|
||||
// server with a finite maximum is restricting continuous mode to a finite
|
||||
// test, which is a reduction the client already models.
|
||||
assert_eq!(
|
||||
negotiated(config(), &requesting(0, 1_000_000_000)).duration_ns,
|
||||
2_000_000_000,
|
||||
"a continuous request takes the maximum"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_configured_maximum_a_duration_is_never_restricted() {
|
||||
let config = || ServerConfig::default();
|
||||
|
||||
assert_eq!(
|
||||
negotiated(config(), &requesting(86_400_000_000_000, 1_000_000_000)).duration_ns,
|
||||
86_400_000_000_000,
|
||||
"a day-long test is the operator's business, not negotiation's"
|
||||
);
|
||||
assert_eq!(
|
||||
negotiated(config(), &requesting(0, 1_000_000_000)).duration_ns,
|
||||
0,
|
||||
"continuous stays continuous"
|
||||
);
|
||||
|
||||
// A zero maximum is stored as no maximum rather than negotiated: Duration
|
||||
// zero on the wire means continuous, so a finite maximum of zero could only
|
||||
// be expressed by turning a client's finite test into an endless one.
|
||||
let zero = ServerConfig::default().with_max_test_duration(Duration::ZERO);
|
||||
assert_eq!(zero.max_test_duration(), None);
|
||||
assert_eq!(
|
||||
negotiated(zero, &requesting(1_000_000_000, 1_000_000_000)).duration_ns,
|
||||
1_000_000_000
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_requested_interval_is_raised_to_the_configured_minimum() {
|
||||
let config = || ServerConfig::default().with_min_send_interval(Duration::from_millis(100));
|
||||
|
||||
assert_eq!(
|
||||
negotiated(config(), &requesting(3_000_000_000, 20_000_000)).interval_ns,
|
||||
100_000_000,
|
||||
"a faster interval is raised to the minimum"
|
||||
);
|
||||
assert_eq!(
|
||||
negotiated(config(), &requesting(3_000_000_000, 500_000_000)).interval_ns,
|
||||
500_000_000,
|
||||
"an interval above the minimum is left alone"
|
||||
);
|
||||
// An absent Interval is the wire default zero, not a refusal — an explicit
|
||||
// zero is rejected during request admission and never reaches negotiation —
|
||||
// so it is raised like any other value below the floor.
|
||||
assert_eq!(
|
||||
negotiated(config(), &requesting(3_000_000_000, 0)).interval_ns,
|
||||
100_000_000,
|
||||
"an absent interval takes the minimum"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_negotiated_interval_is_capped_at_a_quarter_of_the_idle_timeout() {
|
||||
// Without the cap, a client sending at the interval it was handed would
|
||||
// idle its own session out.
|
||||
let config = ServerConfig::default().with_idle_timeout(Duration::from_secs(8));
|
||||
|
||||
assert_eq!(
|
||||
negotiated(config, &requesting(3_000_000_000, 5_000_000_000)).interval_ns,
|
||||
2_000_000_000
|
||||
);
|
||||
|
||||
// A timeout whose quarter is past what the wire can express caps nothing,
|
||||
// because no representable interval reaches it. The quarter is taken before
|
||||
// the conversion to nanoseconds for exactly this reason: saturating first
|
||||
// would put the cap at a quarter of `i64::MAX` and reduce a long interval
|
||||
// the configured timeout never meant to touch.
|
||||
let absurd = ServerConfig::default().with_idle_timeout(Duration::MAX);
|
||||
assert_eq!(
|
||||
negotiated(absurd, &requesting(3_000_000_000, i64::MAX)).interval_ns,
|
||||
i64::MAX
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_idle_cap_wins_over_a_larger_configured_minimum_interval() {
|
||||
// The floor is applied first and the cap second, so a minimum above a
|
||||
// quarter of the idle timeout produces a negotiated interval below that
|
||||
// minimum. This is the configuration the specification records as an
|
||||
// upstream hazard: upstream returns 2 s here and still replenishes reply
|
||||
// allowance every 5 s, so a client obeying the reply is rate-limited.
|
||||
//
|
||||
// `irtt-rs` returns the same 2 s — the cap has to win, or the session
|
||||
// cannot stay alive — and refills at 2 s instead. `rate` proves the
|
||||
// enforcement half; this pins the value the client is told.
|
||||
let config = ServerConfig::default()
|
||||
.with_min_send_interval(Duration::from_secs(5))
|
||||
.with_idle_timeout(Duration::from_secs(8));
|
||||
|
||||
assert_eq!(
|
||||
negotiated(config, &requesting(3_000_000_000, 10_000_000)).interval_ns,
|
||||
2_000_000_000
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_idle_timeout_caps_nothing() {
|
||||
// A zero timeout expires a session at the first evaluation, so there is no
|
||||
// liveness to protect and a quarter of zero must not be read as a cap of
|
||||
// zero — which would negotiate an interval the client would reject.
|
||||
let config = ServerConfig::default()
|
||||
.with_idle_timeout(Duration::ZERO)
|
||||
.with_min_send_interval(Duration::from_millis(100));
|
||||
|
||||
assert_eq!(
|
||||
negotiated(config, &requesting(3_000_000_000, 3_600_000_000_000)).interval_ns,
|
||||
3_600_000_000_000
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_configuration_restricts_neither_capability() {
|
||||
// Both controls are opt-in, so an existing configuration negotiates exactly
|
||||
// what it did before they existed.
|
||||
let config = ServerConfig::default();
|
||||
|
||||
assert_eq!(config.timestamp_allowance(), TimestampAllowance::Dual);
|
||||
assert!(config.dscp_allowed());
|
||||
assert_eq!(TimestampAllowance::default(), TimestampAllowance::Dual);
|
||||
|
||||
let restricted = config
|
||||
.clone()
|
||||
.with_timestamp_allowance(TimestampAllowance::Single)
|
||||
.with_dscp_allowed(false);
|
||||
assert_eq!(restricted.timestamp_allowance(), TimestampAllowance::Single);
|
||||
assert!(!restricted.dscp_allowed());
|
||||
assert_ne!(
|
||||
restricted, config,
|
||||
"the policy is part of the configuration"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_requested_stamp_at_is_reduced_to_the_configured_allowance() {
|
||||
// The whole observed mapping, driven through real opens and read out of the
|
||||
// reply, because the reply is what a client enforces its own restriction
|
||||
// policy against. Only one row substitutes rather than removes: a request
|
||||
// for both instants under a single-timestamp allowance is answered with the
|
||||
// *midpoint*, which still describes both, rather than with whichever of the
|
||||
// two the server preferred.
|
||||
for (requested, allowance, expected) in [
|
||||
(StampAt::None, TimestampAllowance::Dual, StampAt::None),
|
||||
(StampAt::None, TimestampAllowance::Single, StampAt::None),
|
||||
(StampAt::None, TimestampAllowance::None, StampAt::None),
|
||||
(StampAt::Send, TimestampAllowance::Dual, StampAt::Send),
|
||||
(StampAt::Send, TimestampAllowance::Single, StampAt::Send),
|
||||
(StampAt::Send, TimestampAllowance::None, StampAt::None),
|
||||
(StampAt::Receive, TimestampAllowance::Dual, StampAt::Receive),
|
||||
(
|
||||
StampAt::Receive,
|
||||
TimestampAllowance::Single,
|
||||
StampAt::Receive,
|
||||
),
|
||||
(StampAt::Receive, TimestampAllowance::None, StampAt::None),
|
||||
(StampAt::Both, TimestampAllowance::Dual, StampAt::Both),
|
||||
(StampAt::Both, TimestampAllowance::Single, StampAt::Midpoint),
|
||||
(StampAt::Both, TimestampAllowance::None, StampAt::None),
|
||||
(
|
||||
StampAt::Midpoint,
|
||||
TimestampAllowance::Dual,
|
||||
StampAt::Midpoint,
|
||||
),
|
||||
(
|
||||
StampAt::Midpoint,
|
||||
TimestampAllowance::Single,
|
||||
StampAt::Midpoint,
|
||||
),
|
||||
(StampAt::Midpoint, TimestampAllowance::None, StampAt::None),
|
||||
] {
|
||||
let params = Params {
|
||||
stamp_at: requested,
|
||||
clock: Clock::Both,
|
||||
..client_params()
|
||||
};
|
||||
let negotiated = negotiated(
|
||||
ServerConfig::default().with_timestamp_allowance(allowance),
|
||||
¶ms,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
negotiated.stamp_at, expected,
|
||||
"{requested:?} under {allowance:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
negotiated.clock,
|
||||
Clock::Both,
|
||||
"{requested:?} under {allowance:?}: the allowance restricts placement, not the clock"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_timestamp_restriction_never_rewrites_the_requested_clock() {
|
||||
// The clean evidence describes a timestamp *allowance*, and says nothing
|
||||
// about restricting clock domains. A server that quietly changed the clock
|
||||
// too would report a session the client never asked for — and a client
|
||||
// enforcing strict negotiation would refuse an otherwise serviceable one.
|
||||
let single = negotiated(
|
||||
ServerConfig::default().with_timestamp_allowance(TimestampAllowance::Single),
|
||||
&Params {
|
||||
stamp_at: StampAt::Both,
|
||||
clock: Clock::Wall,
|
||||
..client_params()
|
||||
},
|
||||
);
|
||||
assert_eq!(single.stamp_at, StampAt::Midpoint);
|
||||
assert_eq!(single.clock, Clock::Wall);
|
||||
|
||||
let none = negotiated(
|
||||
ServerConfig::default().with_timestamp_allowance(TimestampAllowance::None),
|
||||
&Params {
|
||||
stamp_at: StampAt::Both,
|
||||
clock: Clock::Both,
|
||||
..client_params()
|
||||
},
|
||||
);
|
||||
assert_eq!(none.stamp_at, StampAt::None);
|
||||
assert_eq!(
|
||||
none.clock,
|
||||
Clock::Both,
|
||||
"the clock tag stays exactly as requested, selecting nothing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_allowed_dscp_is_negotiated_stored_and_transported_unchanged() {
|
||||
// The default, and the behavioral compatibility this policy must not
|
||||
// disturb: the raw byte reaches the reply, the session and the transport.
|
||||
let requested = Params {
|
||||
dscp: 0xbb,
|
||||
..client_params()
|
||||
};
|
||||
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
|
||||
let open = core
|
||||
.handle_datagram(peer(), &open_request(&requested, None))
|
||||
.unwrap()
|
||||
.expect("the open must be answered");
|
||||
|
||||
let negotiated = expect_normal_open_reply(&open, None).params;
|
||||
assert_eq!(negotiated.dscp, 0xbb);
|
||||
assert_eq!(
|
||||
core.session(TOKEN_A)
|
||||
.expect("the session must be live")
|
||||
.params()
|
||||
.dscp,
|
||||
0xbb
|
||||
);
|
||||
|
||||
let reply = core
|
||||
.handle_datagram(peer(), &echo_request(TOKEN_A, 0, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.expect("an admissible echo must be answered");
|
||||
expect_echo_reply(&reply, &negotiated, None);
|
||||
assert_eq!(i64::from(reply.traffic_class()), 0xbb);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_disallowed_dscp_is_negotiated_to_zero_and_the_session_runs_unmarked() {
|
||||
// The open is not refused: the server simply will not provide the marking,
|
||||
// and says so in the value it returns. Nothing downstream needs a second
|
||||
// record of the policy — the negotiated zero is what the transport reads.
|
||||
let requested = Params {
|
||||
dscp: 0xb8,
|
||||
..client_params()
|
||||
};
|
||||
let mut core = core_with_tokens(
|
||||
unthrottled().with_dscp_allowed(false),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
);
|
||||
let open = core
|
||||
.handle_datagram(peer(), &open_request(&requested, None))
|
||||
.unwrap()
|
||||
.expect("a disallowed DSCP must not refuse the open");
|
||||
|
||||
let negotiated = expect_normal_open_reply(&open, None).params;
|
||||
assert_eq!(negotiated.dscp, 0, "restricted to zero");
|
||||
assert_eq!(
|
||||
core.session(TOKEN_A)
|
||||
.expect("the session must be live")
|
||||
.params()
|
||||
.dscp,
|
||||
0,
|
||||
"and stored as the value the session will be run with"
|
||||
);
|
||||
|
||||
let reply = core
|
||||
.handle_datagram(peer(), &echo_request(TOKEN_A, 0, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.expect("an unmarked session is an ordinary session");
|
||||
expect_echo_reply(&reply, &negotiated, None);
|
||||
assert_eq!(reply.traffic_class(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_disallowed_dscp_restricts_a_value_no_socket_could_carry_too() {
|
||||
// Restriction runs during negotiation, before the transport's
|
||||
// "out-of-range means unmarked" fallback is relevant at all: these values
|
||||
// never reach the session, so the reply is honest rather than merely
|
||||
// harmless.
|
||||
for dscp in [-1, 256, i64::MAX] {
|
||||
let requested = Params {
|
||||
dscp,
|
||||
..client_params()
|
||||
};
|
||||
let mut core = core_with_tokens(
|
||||
unthrottled().with_dscp_allowed(false),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
);
|
||||
let open = core
|
||||
.handle_datagram(peer(), &open_request(&requested, None))
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("an open requesting DSCP {dscp} must still be answered"));
|
||||
|
||||
let negotiated = expect_normal_open_reply(&open, None).params;
|
||||
assert_eq!(negotiated.dscp, 0, "requested {dscp}");
|
||||
assert_eq!(
|
||||
core.session(TOKEN_A)
|
||||
.expect("the session must be live")
|
||||
.params()
|
||||
.dscp,
|
||||
0,
|
||||
"requested {dscp}"
|
||||
);
|
||||
|
||||
let reply = core
|
||||
.handle_datagram(peer(), &echo_request(TOKEN_A, 0, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("the session must serve echoes after restricting {dscp}"));
|
||||
expect_echo_reply(&reply, &negotiated, None);
|
||||
assert_eq!(reply.traffic_class(), 0, "requested {dscp}");
|
||||
}
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
use irtt_proto::{verify_packet_hmac, Clock, Params, StampAt};
|
||||
|
||||
use super::support::{
|
||||
client_params, core_with_tokens, expect_no_test_reply, no_test_request, open_request,
|
||||
open_request_with_raw_params, param_int, peer, unthrottled, ScriptedTokens, KEY,
|
||||
};
|
||||
use crate::{ServerConfig, TimestampAllowance};
|
||||
|
||||
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||
const TOKEN_B: u64 = 0x1112_1314_1516_1718;
|
||||
|
||||
#[test]
|
||||
fn a_no_test_open_replies_with_a_zero_token_and_creates_nothing() {
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let mut core = core_with_tokens(ServerConfig::default(), tokens.clone());
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &no_test_request(&client_params(), None))
|
||||
.unwrap()
|
||||
.expect("a no-test open must be answered");
|
||||
|
||||
let reply = expect_no_test_reply(&packet, None);
|
||||
assert_eq!(
|
||||
reply.params,
|
||||
Params {
|
||||
protocol_version: 1,
|
||||
..client_params()
|
||||
}
|
||||
);
|
||||
assert_eq!(core.session_count(), 0, "no-test creates no session");
|
||||
assert_eq!(
|
||||
tokens.remaining(),
|
||||
1,
|
||||
"no-test must not draw a session token"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_no_test_open_negotiates_exactly_as_a_normal_open_does() {
|
||||
let requested = client_params();
|
||||
|
||||
let mut normal_core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
let normal = normal_core
|
||||
.handle_datagram(peer(), &open_request(&requested, None))
|
||||
.unwrap()
|
||||
.expect("normal open must be answered");
|
||||
|
||||
let mut no_test_core =
|
||||
core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
let no_test = no_test_core
|
||||
.handle_datagram(peer(), &no_test_request(&requested, None))
|
||||
.unwrap()
|
||||
.expect("no-test open must be answered");
|
||||
|
||||
assert_eq!(
|
||||
expect_no_test_reply(&no_test, None).params,
|
||||
super::support::decode_reply(&normal, None).params
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_no_test_open_is_rejected_on_the_same_parameters_a_normal_open_is() {
|
||||
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
|
||||
let request = {
|
||||
let mut packet = no_test_request(&client_params(), None);
|
||||
// Replace the parameter payload with an explicit zero duration.
|
||||
packet.truncate(4);
|
||||
packet.extend_from_slice(¶m_int(2, 0));
|
||||
packet
|
||||
};
|
||||
|
||||
assert_eq!(core.handle_datagram(peer(), &request).unwrap(), None);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_no_test_open_selecting_timestamps_without_a_clock_is_silently_refused() {
|
||||
// No-test exists to tell a client what the session would be, so it must not
|
||||
// report a session that could not exist. The effective-parameter check runs
|
||||
// before the no-test path branches off, so both open forms refuse the same
|
||||
// incoherent timestamp request.
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let mut core = core_with_tokens(ServerConfig::default(), tokens.clone());
|
||||
|
||||
let requested = Params {
|
||||
clock: Clock::Unspecified,
|
||||
stamp_at: StampAt::Both,
|
||||
..client_params()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &no_test_request(&requested, None))
|
||||
.expect("an unusable timestamp request is not a server error"),
|
||||
None
|
||||
);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
assert_eq!(tokens.remaining(), 1, "no token may be drawn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_no_test_open_reports_the_reduced_packet_length() {
|
||||
// No-test answers what the session *would* be, so it has to report the same
|
||||
// reduced length a session-creating open would enforce — while still
|
||||
// creating nothing and drawing no token.
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let mut core = core_with_tokens(
|
||||
ServerConfig::default().with_max_packet_length(64),
|
||||
tokens.clone(),
|
||||
);
|
||||
let requested = Params {
|
||||
length: 1000,
|
||||
..client_params()
|
||||
};
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &no_test_request(&requested, None))
|
||||
.unwrap()
|
||||
.expect("a reducible length must still be answered");
|
||||
|
||||
assert_eq!(expect_no_test_reply(&packet, None).params.length, 64);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
assert_eq!(tokens.remaining(), 1, "no-test must not draw a token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_no_test_open_is_refused_when_its_echo_field_block_exceeds_the_maximum() {
|
||||
// A maximum below the mandatory echo block describes a session that could
|
||||
// not exist at all, so no-test must not report one. Both open forms run the
|
||||
// same effective-parameter check before no-test branches off.
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let mut core = core_with_tokens(
|
||||
ServerConfig::default().with_max_packet_length(0),
|
||||
tokens.clone(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &no_test_request(&client_params(), None))
|
||||
.expect("an unservable packet size is not a server error"),
|
||||
None
|
||||
);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
assert_eq!(tokens.remaining(), 1, "no token may be drawn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_no_test_open_is_still_answered_when_no_session_could_be_created() {
|
||||
// No-test consumes no capacity, so it must keep working exactly where a
|
||||
// session-creating open would be refused.
|
||||
let mut refuses_everything = core_with_tokens(
|
||||
ServerConfig::default().with_max_sessions(0),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
);
|
||||
assert_eq!(
|
||||
refuses_everything
|
||||
.handle_datagram(peer(), &open_request(&client_params(), None))
|
||||
.unwrap(),
|
||||
None,
|
||||
"max_sessions 0 refuses a session-creating open"
|
||||
);
|
||||
let packet = refuses_everything
|
||||
.handle_datagram(peer(), &no_test_request(&client_params(), None))
|
||||
.unwrap()
|
||||
.expect("no-test must be served at max_sessions 0");
|
||||
expect_no_test_reply(&packet, None);
|
||||
|
||||
let mut full = core_with_tokens(
|
||||
ServerConfig::default().with_max_sessions(1),
|
||||
ScriptedTokens::new([TOKEN_A, TOKEN_B]),
|
||||
);
|
||||
full.handle_datagram(peer(), &open_request(&client_params(), None))
|
||||
.unwrap()
|
||||
.expect("the first open fills the table");
|
||||
assert_eq!(full.session_count(), 1);
|
||||
|
||||
let packet = full
|
||||
.handle_datagram(peer(), &no_test_request(&client_params(), None))
|
||||
.unwrap()
|
||||
.expect("no-test must be served with a full session table");
|
||||
expect_no_test_reply(&packet, None);
|
||||
assert_eq!(full.session_count(), 1, "no-test disturbed the table");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_authenticated_no_test_reply_is_authenticated_and_still_creates_nothing() {
|
||||
let mut core = core_with_tokens(
|
||||
ServerConfig::default().with_hmac_key(KEY),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
);
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &no_test_request(&client_params(), Some(KEY)))
|
||||
.unwrap()
|
||||
.expect("an authenticated no-test open must be answered");
|
||||
|
||||
expect_no_test_reply(&packet, Some(KEY));
|
||||
verify_packet_hmac(KEY, packet.bytes()).expect("the no-test reply must carry a valid MAC");
|
||||
assert_eq!(core.session_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_no_test_open_is_answered() {
|
||||
// No interval floor, so the reply shows the wire defaults an empty payload
|
||||
// decoded to rather than the timing policy negotiation would apply to them.
|
||||
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
|
||||
|
||||
let mut request = open_request_with_raw_params(&[], None);
|
||||
request[3] |= irtt_proto::FLAG_CLOSE;
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &request)
|
||||
.unwrap()
|
||||
.expect("an empty no-test open must be answered");
|
||||
|
||||
let reply = expect_no_test_reply(&packet, None);
|
||||
assert_eq!(
|
||||
reply.params,
|
||||
Params {
|
||||
protocol_version: 1,
|
||||
..Params::default()
|
||||
}
|
||||
);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_no_test_open_reports_the_restricted_capabilities() {
|
||||
// No-test exists to tell a client what the session would be, so it reports
|
||||
// exactly what the same open would have negotiated under this policy —
|
||||
// while still creating nothing and drawing no token. Capability policy is
|
||||
// not skipped merely because no measurement session follows.
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let mut core = core_with_tokens(
|
||||
ServerConfig::default()
|
||||
.with_timestamp_allowance(TimestampAllowance::Single)
|
||||
.with_dscp_allowed(false),
|
||||
tokens.clone(),
|
||||
);
|
||||
let requested = Params {
|
||||
stamp_at: StampAt::Both,
|
||||
clock: Clock::Both,
|
||||
dscp: 184,
|
||||
..client_params()
|
||||
};
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &no_test_request(&requested, None))
|
||||
.unwrap()
|
||||
.expect("a no-test open must be answered under a restricting policy");
|
||||
|
||||
let reply = expect_no_test_reply(&packet, None);
|
||||
assert_eq!(reply.params.stamp_at, StampAt::Midpoint);
|
||||
assert_eq!(reply.params.clock, Clock::Both, "the clock is untouched");
|
||||
assert_eq!(reply.params.dscp, 0);
|
||||
assert_eq!(reply.token, 0);
|
||||
assert_eq!(core.session_count(), 0, "no-test creates no session");
|
||||
assert_eq!(tokens.remaining(), 1, "no-test must not draw a token");
|
||||
}
|
||||
@@ -1,458 +0,0 @@
|
||||
use irtt_proto::{Clock, Params, ReceivedStats, ServerFill, StampAt, TimestampFields};
|
||||
|
||||
use super::support::{
|
||||
client_params, core_with_tokens, echo_request, expect_echo_reply, expect_normal_open_reply,
|
||||
open_request, open_request_with_raw_params, param_int, peer, unthrottled, ScriptedTokens, KEY,
|
||||
};
|
||||
use crate::{ServerConfig, TimestampAllowance, DEFAULT_MAX_PACKET_LENGTH};
|
||||
|
||||
const TOKEN_A: u64 = 0x7896_b6ab_8771_5213;
|
||||
|
||||
#[test]
|
||||
fn an_ordinary_open_is_answered_and_creates_one_session() {
|
||||
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &open_request(&client_params(), None))
|
||||
.unwrap()
|
||||
.expect("an ordinary open must be answered");
|
||||
|
||||
let reply = expect_normal_open_reply(&packet, None);
|
||||
assert_eq!(reply.token, TOKEN_A);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
|
||||
let session = core.session(TOKEN_A).expect("the session must be live");
|
||||
assert_eq!(session.params(), &reply.params);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_records_the_exact_source_endpoint() {
|
||||
// Address family, address, port and — where present — the IPv6 scope all
|
||||
// form part of session identity, so the endpoint is stored verbatim rather
|
||||
// than normalized to an address.
|
||||
for endpoint in [
|
||||
"198.51.100.7:41234",
|
||||
"198.51.100.7:41235",
|
||||
"[2001:db8::1]:2112",
|
||||
"[fe80::1%3]:2112",
|
||||
] {
|
||||
let endpoint = endpoint.parse().unwrap();
|
||||
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
|
||||
core.handle_datagram(endpoint, &open_request(&client_params(), None))
|
||||
.unwrap()
|
||||
.expect("open must be answered");
|
||||
|
||||
assert_eq!(core.session(TOKEN_A).unwrap().peer(), endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_requested_protocol_version_is_accepted_and_answered_with_one() {
|
||||
// Version mismatch is detected client-side only: the server rewrites the
|
||||
// version and creates a normal session whatever was asked for.
|
||||
for requested in [Some(1), Some(0), Some(2), Some(-1), None] {
|
||||
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
let params = Params {
|
||||
protocol_version: requested.unwrap_or(0),
|
||||
..client_params()
|
||||
};
|
||||
let request = match requested {
|
||||
// An explicit zero cannot be encoded by `Params::encode`, which
|
||||
// omits zero-valued integers, so encode that tag by hand.
|
||||
Some(0) => open_request_with_raw_params(¶m_int(1, 0), None),
|
||||
Some(_) => open_request(¶ms, None),
|
||||
None => open_request(
|
||||
&Params {
|
||||
protocol_version: 0,
|
||||
..params
|
||||
},
|
||||
None,
|
||||
),
|
||||
};
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &request)
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("protocol version {requested:?} must be answered"));
|
||||
|
||||
let reply = expect_normal_open_reply(&packet, None);
|
||||
assert_eq!(reply.params.protocol_version, 1);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_parameter_payload_is_answered_with_only_a_version() {
|
||||
// The interval floor is disabled here so that the reply carries nothing but
|
||||
// the version: with one configured, an absent Interval negotiates up to it
|
||||
// and is encoded, which is interval policy rather than what this test is
|
||||
// about.
|
||||
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &open_request_with_raw_params(&[], None))
|
||||
.unwrap()
|
||||
.expect("an empty parameter payload is a valid open");
|
||||
|
||||
// header (4) + token (8) + ProtocolVersion=1 (2). Every other parameter
|
||||
// negotiates to its wire-default zero and is omitted entirely, which is
|
||||
// only reachable if absent parameters really did decode as zero.
|
||||
assert_eq!(packet.bytes().len(), 14);
|
||||
|
||||
let reply = expect_normal_open_reply(&packet, None);
|
||||
assert_eq!(
|
||||
reply.params,
|
||||
Params {
|
||||
protocol_version: 1,
|
||||
..Params::default()
|
||||
}
|
||||
);
|
||||
// An unspecified clock is not itself a defect. Only pairing it with a
|
||||
// selected `stamp_at` is, so a session requesting no timestamps at all is
|
||||
// fully valid with no Clock tag.
|
||||
assert_eq!(reply.params.stamp_at, StampAt::None);
|
||||
assert_eq!(reply.params.clock, Clock::Unspecified);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_parameter_tags_are_ignored_and_not_reflected_in_the_reply() {
|
||||
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
|
||||
|
||||
let mut payload = param_int(1, 1);
|
||||
payload.extend_from_slice(¶m_int(42, 7));
|
||||
payload.extend_from_slice(¶m_int(200, 9));
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &open_request_with_raw_params(&payload, None))
|
||||
.unwrap()
|
||||
.expect("unknown tags must not prevent an open");
|
||||
|
||||
let reply = expect_normal_open_reply(&packet, None);
|
||||
assert_eq!(
|
||||
reply.params,
|
||||
Params {
|
||||
protocol_version: 1,
|
||||
..Params::default()
|
||||
}
|
||||
);
|
||||
// The reply is the same 14 bytes an empty payload produces: no unknown tag
|
||||
// was copied through.
|
||||
assert_eq!(packet.bytes().len(), 14);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_timestamps_without_a_clock_is_silently_refused() {
|
||||
// Timestamps from no clock at all describe a session the server could not
|
||||
// run: the negotiated layout carries no timestamp field to fill. The open
|
||||
// is discarded rather than repaired — synthesizing a clock would answer
|
||||
// with a session the client never asked for, and dropping `stamp_at` would
|
||||
// silently discard the measurement it did ask for.
|
||||
//
|
||||
// Only an *absent* Clock tag reaches this: an explicit zero is already out
|
||||
// of range for the decoder, and a conforming client always sends a clock
|
||||
// when it selects timestamps.
|
||||
//
|
||||
// A server whose timestamp allowance is none accepts the very same request,
|
||||
// because then no timestamp is negotiated to be unproducible; see
|
||||
// `a_timestamp_disallowing_server_accepts_a_request_that_omitted_its_clock`.
|
||||
for stamp_at in [
|
||||
StampAt::Send,
|
||||
StampAt::Receive,
|
||||
StampAt::Both,
|
||||
StampAt::Midpoint,
|
||||
] {
|
||||
let requested = Params {
|
||||
clock: Clock::Unspecified,
|
||||
stamp_at,
|
||||
..client_params()
|
||||
};
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let mut core = core_with_tokens(ServerConfig::default(), tokens.clone());
|
||||
|
||||
let reply = core
|
||||
.handle_datagram(peer(), &open_request(&requested, None))
|
||||
.expect("an unusable timestamp request is not a server error");
|
||||
|
||||
assert!(
|
||||
reply.is_none(),
|
||||
"{stamp_at:?} without a clock must be silent"
|
||||
);
|
||||
assert_eq!(
|
||||
core.session_count(),
|
||||
0,
|
||||
"{stamp_at:?} without a clock must create no session"
|
||||
);
|
||||
assert_eq!(
|
||||
tokens.remaining(),
|
||||
1,
|
||||
"{stamp_at:?} without a clock must not consume a token"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_timestamp_disallowing_server_accepts_a_request_that_omitted_its_clock() {
|
||||
// The same request the test above refuses, against a server that provides no
|
||||
// timestamps at all. The restriction runs before the effective-session
|
||||
// check, so `stamp_at` is already none by the time executability is judged:
|
||||
// the absent clock then selects nothing that was going to be reported, and
|
||||
// the session is one the server can run exactly as negotiated.
|
||||
//
|
||||
// This is what the ordering exists for. Validating the *request* instead
|
||||
// would refuse an open that this policy makes perfectly serviceable.
|
||||
let requested = Params {
|
||||
clock: Clock::Unspecified,
|
||||
stamp_at: StampAt::Both,
|
||||
..client_params()
|
||||
};
|
||||
let mut core = core_with_tokens(
|
||||
unthrottled().with_timestamp_allowance(TimestampAllowance::None),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
);
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &open_request(&requested, None))
|
||||
.unwrap()
|
||||
.expect("an omitted clock is safe once no timestamps are negotiated");
|
||||
|
||||
let reply = expect_normal_open_reply(&packet, None);
|
||||
assert_eq!(reply.params.stamp_at, StampAt::None);
|
||||
assert_eq!(
|
||||
reply.params.clock,
|
||||
Clock::Unspecified,
|
||||
"the absent clock tag stays absent"
|
||||
);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
|
||||
let echo = core
|
||||
.handle_datagram(
|
||||
peer(),
|
||||
&echo_request(reply.token, 0, &reply.params, &[], None),
|
||||
)
|
||||
.unwrap()
|
||||
.expect("the session must serve echoes");
|
||||
assert_eq!(
|
||||
expect_echo_reply(&echo, &reply.params, None).timestamps,
|
||||
TimestampFields::default(),
|
||||
"no timestamps were negotiated, so the reply carries none"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_non_length_parameters_survive_negotiation_unchanged() {
|
||||
// Statistics, timestamps and DSCP are what the capability restrictions
|
||||
// change when they are configured; this is the default configuration, which
|
||||
// restricts none of them. Version, an oversized packet
|
||||
// length, the duration maximum and the interval floor and cap are already
|
||||
// restricted and have their own tests; the length here is deliberately
|
||||
// under the default maximum, and the configuration below leaves duration
|
||||
// and interval unrestricted, so this test says nothing about either policy.
|
||||
//
|
||||
// The server fill below is a *valid* descriptor, which is what survives
|
||||
// unchanged. Fill negotiation has its own restriction rule and its own
|
||||
// tests in `fill`.
|
||||
//
|
||||
// This is about what *negotiation* rewrites, which is a separate question
|
||||
// from whether the core will acknowledge the result: an open whose
|
||||
// effective parameters are incoherent is still discarded, as
|
||||
// `selecting_timestamps_without_a_clock_is_silently_refused` shows.
|
||||
let unrestricted = Params {
|
||||
protocol_version: 2,
|
||||
duration_ns: 3_600_000_000_000,
|
||||
interval_ns: 1,
|
||||
length: 1472,
|
||||
received_stats: ReceivedStats::Window,
|
||||
stamp_at: StampAt::Midpoint,
|
||||
clock: Clock::Monotonic,
|
||||
dscp: 184,
|
||||
server_fill: Some(ServerFill {
|
||||
value: "pattern:abcd".to_owned(),
|
||||
}),
|
||||
};
|
||||
|
||||
// No duration maximum is configured by default, and the interval floor is
|
||||
// disabled, so a one-nanosecond interval is left where it was asked for.
|
||||
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &open_request(&unrestricted, None))
|
||||
.unwrap()
|
||||
.expect("open must be answered");
|
||||
|
||||
let reply = expect_normal_open_reply(&packet, None);
|
||||
assert_eq!(
|
||||
reply.params,
|
||||
Params {
|
||||
protocol_version: 1,
|
||||
..unrestricted
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_positive_length_above_the_configured_maximum_is_reduced_to_it() {
|
||||
// The reduced value is what the client is told and what the session holds,
|
||||
// because it is what the server will actually emit. Both have to agree: an
|
||||
// upstream client treats a reply shorter than the negotiated length as
|
||||
// fatal, so an honest reduction at open is the only serviceable answer.
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let mut core = core_with_tokens(
|
||||
ServerConfig::default().with_max_packet_length(64),
|
||||
tokens.clone(),
|
||||
);
|
||||
let requested = Params {
|
||||
length: 1000,
|
||||
..client_params()
|
||||
};
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &open_request(&requested, None))
|
||||
.unwrap()
|
||||
.expect("a reducible length must still be answered");
|
||||
|
||||
let reply = expect_normal_open_reply(&packet, None);
|
||||
assert_eq!(reply.params.length, 64);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
assert_eq!(tokens.remaining(), 0, "one token was consumed");
|
||||
let session = core.session(TOKEN_A).expect("the session must be live");
|
||||
assert_eq!(
|
||||
session.params().length,
|
||||
64,
|
||||
"the session must enforce the reduced length"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_maximum_reduces_an_absurd_requested_length() {
|
||||
// The largest length the wire can carry is representable as a `usize` on a
|
||||
// 64-bit target, so packet sizing would happily have turned it into an
|
||||
// allocation; the configured maximum is what stops it. Restriction happens
|
||||
// during negotiation, before any sizing, so this holds on a 32-bit target
|
||||
// too — the request never reaches a conversion that could fail.
|
||||
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
let requested = Params {
|
||||
length: i64::MAX,
|
||||
..client_params()
|
||||
};
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &open_request(&requested, None))
|
||||
.unwrap()
|
||||
.expect("an absurd length is reduced, not refused");
|
||||
|
||||
let reply = expect_normal_open_reply(&packet, None);
|
||||
assert_eq!(reply.params.length, DEFAULT_MAX_PACKET_LENGTH as i64);
|
||||
let session = core.session(TOKEN_A).expect("the session must be live");
|
||||
assert_eq!(session.params().length, DEFAULT_MAX_PACKET_LENGTH as i64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_open_whose_echo_field_block_exceeds_the_maximum_is_silently_refused() {
|
||||
// Length itself satisfies the policy — zero asks for nothing beyond the
|
||||
// mandatory fields — but the received-statistics fields alone make the
|
||||
// smallest compliant echo reply larger than 20 bytes. Capping the Length
|
||||
// parameter is therefore not enough on its own: this session could never be
|
||||
// answered, so it is never acknowledged.
|
||||
let requested = Params {
|
||||
protocol_version: 1,
|
||||
length: 0,
|
||||
received_stats: ReceivedStats::Both,
|
||||
stamp_at: StampAt::None,
|
||||
clock: Clock::Unspecified,
|
||||
..Params::default()
|
||||
};
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let mut core = core_with_tokens(
|
||||
ServerConfig::default().with_max_packet_length(20),
|
||||
tokens.clone(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &open_request(&requested, None))
|
||||
.expect("an unservable packet size is not a server error"),
|
||||
None
|
||||
);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
assert_eq!(tokens.remaining(), 1, "no token may be drawn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authentication_counts_toward_the_executable_echo_size() {
|
||||
// An authentication field adds 16 bytes to the mandatory echo block, so a
|
||||
// maximum between the two floors admits the same minimal session on an
|
||||
// unauthenticated server and refuses it on an authenticated one. The sizes
|
||||
// themselves come from `irtt_proto`; what this pins is that the server asks
|
||||
// about its own authentication mode rather than assuming the smaller floor.
|
||||
let minimal = Params {
|
||||
protocol_version: 1,
|
||||
..Params::default()
|
||||
};
|
||||
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let mut authenticated = core_with_tokens(
|
||||
ServerConfig::default()
|
||||
.with_hmac_key(KEY)
|
||||
.with_max_packet_length(31),
|
||||
tokens.clone(),
|
||||
);
|
||||
assert_eq!(
|
||||
authenticated
|
||||
.handle_datagram(peer(), &open_request(&minimal, Some(KEY)))
|
||||
.expect("an unservable packet size is not a server error"),
|
||||
None
|
||||
);
|
||||
assert_eq!(authenticated.session_count(), 0);
|
||||
assert_eq!(tokens.remaining(), 1, "no token may be drawn");
|
||||
|
||||
let mut unauthenticated = core_with_tokens(
|
||||
ServerConfig::default().with_max_packet_length(31),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
);
|
||||
let packet = unauthenticated
|
||||
.handle_datagram(peer(), &open_request(&minimal, None))
|
||||
.unwrap()
|
||||
.expect("the same session fits without an authentication field");
|
||||
expect_normal_open_reply(&packet, None);
|
||||
assert_eq!(unauthenticated.session_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_and_out_of_range_length_and_dscp_survive_negotiation() {
|
||||
// These lengths are all at or below the default maximum, and DSCP is
|
||||
// accepted as decoded, including values a socket could never carry:
|
||||
// negotiation restricts neither, and a value that is not a byte is simply
|
||||
// transported unmarked (see `traffic_class`).
|
||||
//
|
||||
// A negative length in particular stays negative in both the reply and the
|
||||
// stored session; the packet-length policy reduces oversized *positive*
|
||||
// lengths and touches nothing else. It does not need clamping here to be
|
||||
// executable: echo packet sizing in `irtt-proto` floors it at the required
|
||||
// field block, so the session this creates can be run as negotiated.
|
||||
for (length, dscp) in [(-1, -1), (0, 0), (1472, 184), (-4096, 512)] {
|
||||
let requested = Params {
|
||||
protocol_version: 1,
|
||||
length,
|
||||
dscp,
|
||||
..Params::default()
|
||||
};
|
||||
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &open_request(&requested, None))
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("length {length} dscp {dscp} must be answered"));
|
||||
|
||||
let reply = expect_normal_open_reply(&packet, None);
|
||||
assert_eq!(reply.params.length, length);
|
||||
assert_eq!(reply.params.dscp, dscp);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
let session = core.session(TOKEN_A).expect("the session must be live");
|
||||
assert_eq!(
|
||||
session.params(),
|
||||
&reply.params,
|
||||
"the session must hold the negotiated values verbatim"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
use irtt_proto::{Clock, Params, ReceivedStats, ServerFill, StampAt};
|
||||
|
||||
use super::support::{
|
||||
core_with_tokens, expect_normal_open_reply, open_request, open_request_with_raw_params,
|
||||
param_int, param_server_fill, peer, unthrottled, ScriptedTokens,
|
||||
};
|
||||
use crate::ServerConfig;
|
||||
|
||||
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||
|
||||
fn reject_case(name: &str, payload: Vec<u8>) {
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let mut core = core_with_tokens(ServerConfig::default(), tokens.clone());
|
||||
|
||||
let reply = core
|
||||
.handle_datagram(peer(), &open_request_with_raw_params(&payload, None))
|
||||
.expect("rejected parameters are not a server error");
|
||||
|
||||
assert!(reply.is_none(), "{name} must be answered with silence");
|
||||
assert_eq!(core.session_count(), 0, "{name} must create no session");
|
||||
assert_eq!(
|
||||
tokens.remaining(),
|
||||
1,
|
||||
"{name} must not consume a session token"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_or_out_of_range_parameters_are_dropped_without_a_session() {
|
||||
// Truncation and overflow.
|
||||
reject_case("truncated tag varint", vec![0x80]);
|
||||
reject_case("truncated value varint", vec![2]);
|
||||
reject_case("truncated multi-byte value", vec![2, 0x80]);
|
||||
reject_case(
|
||||
"varint overflow",
|
||||
vec![
|
||||
1, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||
],
|
||||
);
|
||||
|
||||
// Duration and Interval: present and not positive.
|
||||
reject_case("explicit duration zero", param_int(2, 0));
|
||||
reject_case("negative duration", param_int(2, -1));
|
||||
reject_case("explicit interval zero", param_int(3, 0));
|
||||
reject_case("negative interval", param_int(3, -5));
|
||||
|
||||
// Enum values outside their defined range.
|
||||
reject_case("received stats above range", param_int(5, 4));
|
||||
reject_case("negative received stats", param_int(5, -1));
|
||||
reject_case("stamp at above range", param_int(6, 5));
|
||||
reject_case("explicit clock zero", param_int(7, 0));
|
||||
reject_case("clock above range", param_int(7, 4));
|
||||
|
||||
// Server fill.
|
||||
reject_case(
|
||||
"server fill declaring more than the payload holds",
|
||||
param_server_fill(10, b"ab"),
|
||||
);
|
||||
reject_case(
|
||||
"server fill longer than 32 bytes",
|
||||
param_server_fill(33, b"0123456789abcdef0123456789abcdefx"),
|
||||
);
|
||||
reject_case(
|
||||
"server fill that is not utf-8",
|
||||
param_server_fill(1, &[0xff]),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_duration_interval_and_clock_are_accepted_as_wire_defaults() {
|
||||
// The whole reason the decoder reports presence: an omitted tag and one
|
||||
// explicitly encoded as zero share a value but not a verdict.
|
||||
//
|
||||
// The interval floor is disabled so that the accepted wire default is
|
||||
// visible in the reply. An absent Interval is a value like any other to
|
||||
// negotiation, and the default floor would raise it; that is interval
|
||||
// policy, and `negotiation` tests it.
|
||||
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(
|
||||
peer(),
|
||||
&open_request_with_raw_params(¶m_int(1, 1), None),
|
||||
)
|
||||
.unwrap()
|
||||
.expect("absent duration, interval and clock are all valid");
|
||||
|
||||
let reply = expect_normal_open_reply(&packet, None);
|
||||
assert_eq!(reply.params.duration_ns, 0);
|
||||
assert_eq!(reply.params.interval_ns, 0);
|
||||
assert_eq!(reply.params.clock, Clock::Unspecified);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_optional_parameter_values_survive_unchanged() {
|
||||
for (name, requested) in [
|
||||
(
|
||||
"clock wall",
|
||||
Params {
|
||||
clock: Clock::Wall,
|
||||
..Params::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"clock monotonic",
|
||||
Params {
|
||||
clock: Clock::Monotonic,
|
||||
..Params::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"clock both",
|
||||
Params {
|
||||
clock: Clock::Both,
|
||||
..Params::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"received stats and stamp at",
|
||||
Params {
|
||||
received_stats: ReceivedStats::Both,
|
||||
stamp_at: StampAt::Midpoint,
|
||||
clock: Clock::Wall,
|
||||
..Params::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"positive duration and interval",
|
||||
Params {
|
||||
duration_ns: 1,
|
||||
interval_ns: 1,
|
||||
..Params::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
// Exactly `MAX_SERVER_FILL_BYTES`, and a valid descriptor: the
|
||||
// longest one a client can send is also one this server honors, so
|
||||
// the row stays about the length bound rather than about fill
|
||||
// policy, which `fill` tests on its own.
|
||||
"maximum length server fill",
|
||||
Params {
|
||||
server_fill: Some(ServerFill {
|
||||
value: "pattern:0123456789abcdef01234567".to_owned(),
|
||||
}),
|
||||
..Params::default()
|
||||
},
|
||||
),
|
||||
] {
|
||||
// No interval floor: these rows are about which *values* survive
|
||||
// decoding and negotiation, not about the timing policy applied to
|
||||
// them.
|
||||
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &open_request(&requested, None))
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("{name} must be answered"));
|
||||
|
||||
let reply = expect_normal_open_reply(&packet, None);
|
||||
assert_eq!(
|
||||
reply.params,
|
||||
Params {
|
||||
protocol_version: 1,
|
||||
..requested
|
||||
},
|
||||
"{name} must survive negotiation unchanged"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
//! Per-session reply allowance.
|
||||
//!
|
||||
//! The behavior pinned here is the clean specification's Section 9.3: a session
|
||||
//! starts with its full burst, spends one unit per echo it is answered,
|
||||
//! replenishes at one unit per refill interval, and drops an echo arriving with
|
||||
//! no allowance without a reply and without advancing either reception
|
||||
//! statistic. That last part is what the counts in the *later* replies prove.
|
||||
//!
|
||||
//! The token bucket itself is a black-box inference about the reference server,
|
||||
//! not a protocol requirement, so nothing here reaches into the limiter's
|
||||
//! representation: every assertion is about which datagrams came back.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use irtt_proto::{Clock, Params, ReceivedStats, StampAt};
|
||||
|
||||
use super::support::{
|
||||
core_with_sources, echo_at, echo_params, echo_request, expect_echo_reply, manual_core,
|
||||
open_negotiated, other_peer, peer, ManualClock, ScriptedTokens,
|
||||
};
|
||||
use crate::{OutboundDatagram, ServerConfig};
|
||||
|
||||
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||
const TOKEN_B: u64 = 0x1112_1314_1516_1718;
|
||||
|
||||
const MS: i64 = 1_000_000;
|
||||
const SECOND: i64 = 1_000_000_000;
|
||||
|
||||
/// Params reporting both statistics — the count is how a test tells a served
|
||||
/// request from a dropped one — and requesting `interval_ns`.
|
||||
fn params_at(interval_ns: i64) -> Params {
|
||||
Params {
|
||||
interval_ns,
|
||||
..echo_params(
|
||||
ReceivedStats::Both,
|
||||
StampAt::None,
|
||||
Clock::Unspecified,
|
||||
/* length */ 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The received count a reply reported, or `None` if there was no reply.
|
||||
fn count_of(reply: Option<OutboundDatagram>, params: &Params) -> Option<u32> {
|
||||
let reply = reply?;
|
||||
expect_echo_reply(reply, params, None).recv_count
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blast_is_answered_up_to_the_burst_and_then_replenishes() {
|
||||
// The measured case: a 100 ms minimum interval and a burst of 5 answered
|
||||
// exactly 5 of a blast of 12, and the next 3 requests after a 600 ms pause
|
||||
// were all answered, with the count running 1..5 and then 6, 7, 8.
|
||||
let config = ServerConfig::default()
|
||||
.with_min_send_interval(Duration::from_millis(100))
|
||||
.with_burst_allowance(5);
|
||||
let (mut core, clock, token, negotiated) = manual_core(
|
||||
config,
|
||||
¶ms_at(SECOND),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
None,
|
||||
);
|
||||
// The refill cadence is the configured minimum here, not the negotiated
|
||||
// interval: the ordinary case, where the minimum is the shorter of the two.
|
||||
assert_eq!(negotiated.interval_ns, SECOND);
|
||||
|
||||
for sequence in 0..12 {
|
||||
let count = count_of(
|
||||
echo_at(&mut core, &clock, 0, token, sequence, &negotiated, None),
|
||||
&negotiated,
|
||||
);
|
||||
let expected = (sequence < 5).then_some(sequence + 1);
|
||||
assert_eq!(count, expected, "request {sequence} of the blast");
|
||||
}
|
||||
|
||||
// A pause long enough to refill the whole burst, then three requests spaced
|
||||
// by one refill interval each. The counts continuing 6, 7, 8 is what proves
|
||||
// the seven dropped requests advanced nothing.
|
||||
for (offset, sequence, expected) in [(600, 12, 6), (700, 13, 7), (800, 14, 8)] {
|
||||
let count = count_of(
|
||||
echo_at(
|
||||
&mut core,
|
||||
&clock,
|
||||
offset * MS,
|
||||
token,
|
||||
sequence,
|
||||
&negotiated,
|
||||
None,
|
||||
),
|
||||
&negotiated,
|
||||
);
|
||||
assert_eq!(count, Some(expected), "request at {offset} ms");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_burst_of_one_answers_one_request_per_interval() {
|
||||
// The other measured case: with a burst of 1, a blast of 4 produced 1 reply.
|
||||
let config = ServerConfig::default()
|
||||
.with_min_send_interval(Duration::from_millis(100))
|
||||
.with_burst_allowance(1);
|
||||
let (mut core, clock, token, negotiated) = manual_core(
|
||||
config,
|
||||
¶ms_at(SECOND),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
None,
|
||||
);
|
||||
|
||||
for sequence in 0..4 {
|
||||
let count = count_of(
|
||||
echo_at(&mut core, &clock, 0, token, sequence, &negotiated, None),
|
||||
&negotiated,
|
||||
);
|
||||
assert_eq!(count, (sequence == 0).then_some(1), "request {sequence}");
|
||||
}
|
||||
|
||||
// Exactly one interval later the allowance is back, and the count says the
|
||||
// three dropped requests were never counted.
|
||||
let count = count_of(
|
||||
echo_at(&mut core, &clock, 100 * MS, token, 4, &negotiated, None),
|
||||
&negotiated,
|
||||
);
|
||||
assert_eq!(count, Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_refill_cadence_is_never_slower_than_the_negotiated_interval() {
|
||||
// The deliberate `irtt-rs` divergence, and the reason this test exists.
|
||||
//
|
||||
// A 5 s minimum interval against an 8 s idle timeout negotiates 2 s, because
|
||||
// the idle cap is applied after the floor. The clean specification records
|
||||
// the reference server continuing to replenish every 5 s in that
|
||||
// configuration, so a fully conforming client sending at the 2 s interval it
|
||||
// was handed is rate-limited on every request. `irtt-rs` refills at the
|
||||
// interval it advertised instead.
|
||||
let config = ServerConfig::default()
|
||||
.with_min_send_interval(Duration::from_secs(5))
|
||||
.with_idle_timeout(Duration::from_secs(8))
|
||||
.with_burst_allowance(1);
|
||||
let (mut core, clock, token, negotiated) = manual_core(
|
||||
config,
|
||||
¶ms_at(10 * MS),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
negotiated.interval_ns,
|
||||
2 * SECOND,
|
||||
"the idle cap wins over the larger configured minimum"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
count_of(
|
||||
echo_at(&mut core, &clock, 0, token, 0, &negotiated, None),
|
||||
&negotiated
|
||||
),
|
||||
Some(1),
|
||||
"the first request spends the burst"
|
||||
);
|
||||
|
||||
// One negotiated interval later — and well short of the 5 s the reference
|
||||
// server would have made this client wait.
|
||||
assert_eq!(
|
||||
count_of(
|
||||
echo_at(&mut core, &clock, 2 * SECOND, token, 1, &negotiated, None),
|
||||
&negotiated
|
||||
),
|
||||
Some(2),
|
||||
"a client sending at the interval it was given must not be limited"
|
||||
);
|
||||
assert_eq!(
|
||||
count_of(
|
||||
echo_at(&mut core, &clock, 4 * SECOND, token, 2, &negotiated, None),
|
||||
&negotiated
|
||||
),
|
||||
Some(3),
|
||||
"and must keep being answered at that cadence"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_burst_refuses_every_echo_and_a_zero_interval_refuses_none() {
|
||||
// The two zero cases are deliberately not the same. A zero burst is no
|
||||
// allowance at all; a zero interval is no waiting for allowance.
|
||||
let (mut core, clock, token, negotiated) = manual_core(
|
||||
ServerConfig::default().with_burst_allowance(0),
|
||||
¶ms_at(SECOND),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
None,
|
||||
);
|
||||
for (offset, sequence) in [(0, 0), (SECOND, 1), (60 * SECOND, 2)] {
|
||||
assert!(
|
||||
echo_at(
|
||||
&mut core,
|
||||
&clock,
|
||||
offset,
|
||||
token,
|
||||
sequence,
|
||||
&negotiated,
|
||||
None
|
||||
)
|
||||
.is_none(),
|
||||
"a zero burst answers nothing, however long the wait"
|
||||
);
|
||||
}
|
||||
|
||||
let (mut core, clock, token, negotiated) = manual_core(
|
||||
ServerConfig::default().with_min_send_interval(Duration::ZERO),
|
||||
¶ms_at(SECOND),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
None,
|
||||
);
|
||||
for sequence in 0..12 {
|
||||
assert_eq!(
|
||||
count_of(
|
||||
echo_at(&mut core, &clock, 0, token, sequence, &negotiated, None),
|
||||
&negotiated
|
||||
),
|
||||
Some(sequence + 1),
|
||||
"a zero interval never withholds allowance, whatever the burst"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sibling_sessions_hold_their_allowance_independently() {
|
||||
let config = ServerConfig::default()
|
||||
.with_min_send_interval(Duration::from_millis(100))
|
||||
.with_burst_allowance(1);
|
||||
let clock = ManualClock::at(0);
|
||||
let mut core = core_with_sources(
|
||||
config,
|
||||
ScriptedTokens::new([TOKEN_A, TOKEN_B]),
|
||||
clock.clone(),
|
||||
);
|
||||
let requested = params_at(SECOND);
|
||||
let (token_a, negotiated) = open_negotiated(&mut core, peer(), &requested, None);
|
||||
let (token_b, _) = open_negotiated(&mut core, other_peer(), &requested, None);
|
||||
|
||||
// The first session spends its whole allowance and is then limited.
|
||||
assert!(echo_at(&mut core, &clock, 0, token_a, 0, &negotiated, None).is_some());
|
||||
assert!(echo_at(&mut core, &clock, 0, token_a, 1, &negotiated, None).is_none());
|
||||
|
||||
// The second still has its own, from its own endpoint.
|
||||
clock.set(0);
|
||||
let packet = core
|
||||
.handle_datagram(
|
||||
other_peer(),
|
||||
&echo_request(token_b, 0, &negotiated, &[], None),
|
||||
)
|
||||
.unwrap()
|
||||
.expect("a sibling session's allowance is its own");
|
||||
assert_eq!(
|
||||
expect_echo_reply(&packet, &negotiated, None).recv_count,
|
||||
Some(1)
|
||||
);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
use super::support::{
|
||||
client_params, core_with_tokens, expect_normal_open_reply, open_request, other_peer, peer,
|
||||
ScriptedTokens,
|
||||
};
|
||||
use crate::{ServerConfig, DEFAULT_MAX_SESSIONS};
|
||||
|
||||
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||
const TOKEN_B: u64 = 0x1112_1314_1516_1718;
|
||||
const TOKEN_C: u64 = 0x2122_2324_2526_2728;
|
||||
|
||||
#[test]
|
||||
fn the_default_session_bound_is_finite() {
|
||||
let bound = ServerConfig::default().max_sessions();
|
||||
|
||||
assert_eq!(bound, DEFAULT_MAX_SESSIONS);
|
||||
assert!(bound > 0, "the default bound must admit sessions");
|
||||
assert!(
|
||||
bound < usize::MAX,
|
||||
"there is deliberately no unlimited default"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_opens_from_one_endpoint_create_independent_sessions() {
|
||||
// Opens are never deduplicated by endpoint or by parameters: a client
|
||||
// retransmission legitimately produces a second, independent session.
|
||||
let mut core = core_with_tokens(
|
||||
ServerConfig::default(),
|
||||
ScriptedTokens::new([TOKEN_A, TOKEN_B]),
|
||||
);
|
||||
let request = open_request(&client_params(), None);
|
||||
|
||||
let first = core
|
||||
.handle_datagram(peer(), &request)
|
||||
.unwrap()
|
||||
.expect("first open must be answered");
|
||||
let second = core
|
||||
.handle_datagram(peer(), &request)
|
||||
.unwrap()
|
||||
.expect("second open must be answered");
|
||||
|
||||
let first = expect_normal_open_reply(&first, None);
|
||||
let second = expect_normal_open_reply(&second, None);
|
||||
|
||||
assert_ne!(first.token, 0);
|
||||
assert_ne!(second.token, 0);
|
||||
assert_ne!(first.token, second.token);
|
||||
assert_eq!(core.session_count(), 2);
|
||||
assert_eq!(core.session(first.token).unwrap().peer(), peer());
|
||||
assert_eq!(core.session(second.token).unwrap().peer(), peer());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_session_table_drops_new_opens_without_evicting_anything() {
|
||||
let tokens = ScriptedTokens::new([TOKEN_A, TOKEN_B, TOKEN_C]);
|
||||
let mut core = core_with_tokens(ServerConfig::default().with_max_sessions(2), tokens.clone());
|
||||
let request = open_request(&client_params(), None);
|
||||
|
||||
core.handle_datagram(peer(), &request)
|
||||
.unwrap()
|
||||
.expect("first open must be answered");
|
||||
core.handle_datagram(other_peer(), &request)
|
||||
.unwrap()
|
||||
.expect("second open must be answered");
|
||||
assert_eq!(core.session_count(), 2);
|
||||
assert_eq!(core.session(TOKEN_A).unwrap().peer(), peer());
|
||||
assert_eq!(core.session(TOKEN_B).unwrap().peer(), other_peer());
|
||||
|
||||
// Refusal is silence: the protocol has no rejection reply, so the client
|
||||
// simply sees an open timeout.
|
||||
assert_eq!(core.handle_datagram(peer(), &request).unwrap(), None);
|
||||
|
||||
assert_eq!(core.session_count(), 2, "nothing may be evicted");
|
||||
assert_eq!(core.session(TOKEN_A).unwrap().peer(), peer());
|
||||
assert_eq!(core.session(TOKEN_B).unwrap().peer(), other_peer());
|
||||
assert_eq!(core.session(TOKEN_C), None);
|
||||
assert_eq!(
|
||||
tokens.remaining(),
|
||||
1,
|
||||
"a refused open must not commit a token"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_session_bound_refuses_every_session_creating_open() {
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let mut core = core_with_tokens(ServerConfig::default().with_max_sessions(0), tokens.clone());
|
||||
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &open_request(&client_params(), None))
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
assert_eq!(tokens.remaining(), 1);
|
||||
}
|
||||
@@ -1,55 +1,14 @@
|
||||
use crate::{token::TokenSource, ServerConfig, ServerCore, ServerError};
|
||||
use irtt_proto::{
|
||||
decode_open_reply, encode_request, OpenReply, Params, RequestToEncode, FLAG_HMAC, FLAG_OPEN,
|
||||
FLAG_REPLY,
|
||||
};
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
net::SocketAddr,
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use irtt_proto::{
|
||||
decode_echo_reply, decode_open_reply, encode_request, varint, EchoReply, OpenReply,
|
||||
PacketLayout, Params, RequestToEncode, FLAG_CLOSE, FLAG_HMAC, FLAG_OPEN, FLAG_REPLY,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
clock::{ClockSample, ClockSource},
|
||||
core::{OutboundDatagram, ServerCore},
|
||||
error::ServerError,
|
||||
token::TokenSource,
|
||||
ServerConfig,
|
||||
};
|
||||
|
||||
pub(crate) const KEY: &[u8] = b"correctkey";
|
||||
pub(crate) const OTHER_KEY: &[u8] = b"wrongkey";
|
||||
|
||||
/// A default configuration with the minimum send interval disabled.
|
||||
///
|
||||
/// One knob, two consequences, and tests about neither of them want both gone:
|
||||
/// no requested interval is raised during negotiation, and no otherwise
|
||||
/// admissible echo ever waits for rate allowance. That keeps a test about
|
||||
/// parameter pass-through, receive statistics or timestamps from silently
|
||||
/// becoming a test of interval policy — and, since these tests drive the core as
|
||||
/// fast as the machine will run, from being rate-limited by a floor they never
|
||||
/// meant to configure.
|
||||
///
|
||||
/// Tests that *are* about interval negotiation, rate limiting or lifetime build
|
||||
/// their configuration explicitly.
|
||||
pub(crate) fn unthrottled() -> ServerConfig {
|
||||
ServerConfig::default().with_min_send_interval(Duration::ZERO)
|
||||
}
|
||||
|
||||
pub(crate) fn peer() -> SocketAddr {
|
||||
"198.51.100.7:41234".parse().unwrap()
|
||||
}
|
||||
|
||||
pub(crate) fn other_peer() -> SocketAddr {
|
||||
"198.51.100.7:41235".parse().unwrap()
|
||||
}
|
||||
|
||||
/// A token source handing out a fixed script of values, then failing.
|
||||
///
|
||||
/// Draining the script is itself a failure rather than a wrap-around, so a test
|
||||
/// that draws more tokens than it scripted is a loud test bug instead of a
|
||||
/// silent repeat.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ScriptedTokens {
|
||||
values: Arc<Mutex<VecDeque<Result<u64, ServerError>>>>,
|
||||
@@ -71,11 +30,6 @@ impl ScriptedTokens {
|
||||
values: Arc::new(Mutex::new(VecDeque::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// How many scripted values are still unused.
|
||||
pub(crate) fn remaining(&self) -> usize {
|
||||
self.values.lock().unwrap().len()
|
||||
}
|
||||
}
|
||||
|
||||
impl TokenSource for ScriptedTokens {
|
||||
@@ -90,263 +44,14 @@ impl TokenSource for ScriptedTokens {
|
||||
}
|
||||
}
|
||||
|
||||
/// A clock handing out a fixed script of samples, then failing loudly.
|
||||
///
|
||||
/// Running the script dry is a test bug, not a wrap-around: a test that asserts
|
||||
/// timestamps must know exactly which sample each field came from. The core
|
||||
/// takes one sample per authenticated request — when it classifies a datagram as
|
||||
/// an echo, or after authenticating an open or close — and one more when an
|
||||
/// admitted echo is about to be answered.
|
||||
///
|
||||
/// Tests about lifetime and rate want [`ManualClock`] instead: they care about
|
||||
/// *when* a datagram arrives, not about how many times the core reads the clock
|
||||
/// on the way.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ScriptedClock {
|
||||
samples: Arc<Mutex<VecDeque<ClockSample>>>,
|
||||
pub(crate) fn peer() -> SocketAddr {
|
||||
"198.51.100.7:41234".parse().unwrap()
|
||||
}
|
||||
|
||||
impl ScriptedClock {
|
||||
pub(crate) fn new<I>(samples: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = ClockSample>,
|
||||
{
|
||||
Self {
|
||||
samples: Arc::new(Mutex::new(samples.into_iter().collect())),
|
||||
}
|
||||
}
|
||||
|
||||
/// How many scripted samples are still unused.
|
||||
pub(crate) fn remaining(&self) -> usize {
|
||||
self.samples.lock().unwrap().len()
|
||||
}
|
||||
}
|
||||
|
||||
impl ClockSource for ScriptedClock {
|
||||
fn sample(&mut self) -> ClockSample {
|
||||
self.samples
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.expect("the scripted clock ran out: script one sample per echo received, and one more per echo answered")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn sample(wall_ns: i64, mono_ns: i64) -> ClockSample {
|
||||
ClockSample { wall_ns, mono_ns }
|
||||
}
|
||||
|
||||
/// A clock standing still at an instant the test moves by hand.
|
||||
///
|
||||
/// Lifetime and rate behavior is a question of when a datagram arrives, so these
|
||||
/// tests set the time, send a packet, and set it again — with no sleeps, no
|
||||
/// tolerances and nothing riding on how many times the core reads the clock.
|
||||
/// [`ScriptedClock`] remains for the timestamp tests, where every individual
|
||||
/// reading is the point.
|
||||
///
|
||||
/// Both domains read the same value. Lifetime decisions are monotonic-only by
|
||||
/// design, and no test here asserts a wall timestamp, so keeping the pair equal
|
||||
/// makes the readings obviously ordered.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ManualClock {
|
||||
now_ns: Arc<Mutex<i64>>,
|
||||
}
|
||||
|
||||
impl ManualClock {
|
||||
pub(crate) fn at(now_ns: i64) -> Self {
|
||||
Self {
|
||||
now_ns: Arc::new(Mutex::new(now_ns)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves the clock to an exact instant. Deliberately absolute: a test that
|
||||
/// pins a deadline reads better stating the instant than accumulating
|
||||
/// offsets.
|
||||
pub(crate) fn set(&self, now_ns: i64) {
|
||||
*self.now_ns.lock().unwrap() = now_ns;
|
||||
}
|
||||
}
|
||||
|
||||
impl ClockSource for ManualClock {
|
||||
fn sample(&mut self) -> ClockSample {
|
||||
let now_ns = *self.now_ns.lock().unwrap();
|
||||
ClockSample {
|
||||
wall_ns: now_ns,
|
||||
mono_ns: now_ns,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A core with deterministic tokens, so session identity is assertable. Its
|
||||
/// clock is the production one, which is fine for everything that does not
|
||||
/// assert timestamp values.
|
||||
pub(crate) fn core_with_tokens(config: ServerConfig, tokens: ScriptedTokens) -> ServerCore {
|
||||
ServerCore::with_token_source(config, Box::new(tokens))
|
||||
}
|
||||
|
||||
/// A core with both nondeterministic sources chosen, for timestamp, rate and
|
||||
/// lifetime tests.
|
||||
pub(crate) fn core_with_sources(
|
||||
config: ServerConfig,
|
||||
tokens: ScriptedTokens,
|
||||
clock: impl ClockSource + 'static,
|
||||
) -> ServerCore {
|
||||
ServerCore::with_sources(config, Box::new(tokens), Box::new(clock))
|
||||
}
|
||||
|
||||
/// An otherwise default core that authenticates iff `hmac_key` is set.
|
||||
///
|
||||
/// Most behavior has to hold identically with and without authentication, so
|
||||
/// tests loop over both and build the core from the key.
|
||||
pub(crate) fn core_for(hmac_key: Option<&[u8]>, tokens: ScriptedTokens) -> ServerCore {
|
||||
let mut config = unthrottled();
|
||||
if let Some(key) = hmac_key {
|
||||
config = config.with_hmac_key(key);
|
||||
}
|
||||
core_with_tokens(config, tokens)
|
||||
}
|
||||
|
||||
/// A core whose clock the test moves by hand, with one session already open.
|
||||
///
|
||||
/// The clock starts at monotonic zero and the session is opened there, so every
|
||||
/// instant a rate or lifetime test names reads as an offset from the open.
|
||||
pub(crate) fn manual_core(
|
||||
config: ServerConfig,
|
||||
requested: &Params,
|
||||
tokens: ScriptedTokens,
|
||||
hmac_key: Option<&[u8]>,
|
||||
) -> (ServerCore, ManualClock, u64, Params) {
|
||||
let clock = ManualClock::at(0);
|
||||
let mut core = core_with_sources(config, tokens, clock.clone());
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), requested, hmac_key);
|
||||
(core, clock, token, negotiated)
|
||||
}
|
||||
|
||||
/// Sends one echo as it would arrive at monotonic instant `at_ns`.
|
||||
pub(crate) fn echo_at(
|
||||
core: &mut ServerCore,
|
||||
clock: &ManualClock,
|
||||
at_ns: i64,
|
||||
token: u64,
|
||||
sequence: u32,
|
||||
params: &Params,
|
||||
hmac_key: Option<&[u8]>,
|
||||
) -> Option<OutboundDatagram> {
|
||||
clock.set(at_ns);
|
||||
core.handle_datagram(
|
||||
peer(),
|
||||
&echo_request(token, sequence, params, &[], hmac_key),
|
||||
)
|
||||
.expect("a rejected or rate-limited echo is not an internal error")
|
||||
}
|
||||
|
||||
/// Opens one ordinary session from `endpoint` and returns its token.
|
||||
pub(crate) fn open_session(
|
||||
core: &mut ServerCore,
|
||||
endpoint: SocketAddr,
|
||||
hmac_key: Option<&[u8]>,
|
||||
) -> u64 {
|
||||
let packet = core
|
||||
.handle_datagram(endpoint, &open_request(&client_params(), hmac_key))
|
||||
.unwrap()
|
||||
.expect("the session-creating open must be answered");
|
||||
expect_normal_open_reply(&packet, hmac_key).token
|
||||
}
|
||||
|
||||
/// Opens one session carrying `params` and returns its token together with the
|
||||
/// parameters the server negotiated for it.
|
||||
///
|
||||
/// Echo tests need both: the token addresses the session, and the negotiated
|
||||
/// params are what the reply is laid out from and what decoding it requires.
|
||||
pub(crate) fn open_negotiated(
|
||||
core: &mut ServerCore,
|
||||
endpoint: SocketAddr,
|
||||
params: &Params,
|
||||
hmac_key: Option<&[u8]>,
|
||||
) -> (u64, Params) {
|
||||
let packet = core
|
||||
.handle_datagram(endpoint, &open_request(params, hmac_key))
|
||||
.unwrap()
|
||||
.expect("the session-creating open must be answered");
|
||||
let reply = expect_normal_open_reply(&packet, hmac_key);
|
||||
(reply.token, reply.params)
|
||||
}
|
||||
|
||||
/// Params requesting exactly the echo layout a test cares about, with the
|
||||
/// duration and interval a well-behaved client sends.
|
||||
pub(crate) fn echo_params(
|
||||
received_stats: irtt_proto::ReceivedStats,
|
||||
stamp_at: irtt_proto::StampAt,
|
||||
clock: irtt_proto::Clock,
|
||||
length: i64,
|
||||
) -> Params {
|
||||
Params {
|
||||
received_stats,
|
||||
stamp_at,
|
||||
clock,
|
||||
length,
|
||||
..client_params()
|
||||
}
|
||||
}
|
||||
|
||||
/// Encodes an echo request. `params` supplies only the *request's* own sizing;
|
||||
/// it is deliberately a separate argument from the session's negotiated params
|
||||
/// so tests can send requests shorter and longer than the session negotiated.
|
||||
pub(crate) fn echo_request(
|
||||
token: u64,
|
||||
sequence: u32,
|
||||
params: &Params,
|
||||
payload: &[u8],
|
||||
hmac_key: Option<&[u8]>,
|
||||
) -> Vec<u8> {
|
||||
encode_request(
|
||||
RequestToEncode::Echo {
|
||||
token,
|
||||
sequence,
|
||||
params,
|
||||
payload,
|
||||
},
|
||||
hmac_key,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Decodes an echo reply the server produced and checks the flags every reply
|
||||
/// must carry. Decoding verifies the MAC against `hmac_key` on the way.
|
||||
pub(crate) fn expect_echo_reply(
|
||||
packet: impl AsRef<[u8]>,
|
||||
params: &Params,
|
||||
hmac_key: Option<&[u8]>,
|
||||
) -> EchoReply {
|
||||
let reply = decode_echo_reply(packet.as_ref(), params, hmac_key)
|
||||
.expect("server echo reply must decode");
|
||||
// Exact equality, so Open and Close are pinned clear as well: Open makes an
|
||||
// upstream client abort, and Close belongs to the server-initiated close
|
||||
// slice.
|
||||
let expected = FLAG_REPLY | if hmac_key.is_some() { FLAG_HMAC } else { 0 };
|
||||
assert_eq!(reply.flags, expected, "echo reply flags");
|
||||
reply
|
||||
}
|
||||
|
||||
/// Decodes a server-initiated close: an ordinary echo reply carrying Close.
|
||||
///
|
||||
/// The flags are compared exactly, so Open is pinned clear — a reply with it set
|
||||
/// makes an upstream client abort — and the reply is decoded and its MAC checked
|
||||
/// by the same path as any other, which is the point: the close is not a
|
||||
/// separate packet kind.
|
||||
pub(crate) fn expect_closing_echo_reply(
|
||||
packet: impl AsRef<[u8]>,
|
||||
params: &Params,
|
||||
hmac_key: Option<&[u8]>,
|
||||
) -> EchoReply {
|
||||
let reply = decode_echo_reply(packet.as_ref(), params, hmac_key)
|
||||
.expect("a closing echo reply must decode");
|
||||
let expected = FLAG_REPLY | FLAG_CLOSE | if hmac_key.is_some() { FLAG_HMAC } else { 0 };
|
||||
assert_eq!(reply.flags, expected, "closing echo reply flags");
|
||||
reply
|
||||
}
|
||||
|
||||
/// Encodes a normal open request carrying `params`.
|
||||
pub(crate) fn open_request(params: &Params, hmac_key: Option<&[u8]>) -> Vec<u8> {
|
||||
encode_request(
|
||||
RequestToEncode::Open {
|
||||
@@ -358,86 +63,6 @@ pub(crate) fn open_request(params: &Params, hmac_key: Option<&[u8]>) -> Vec<u8>
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Encodes a no-test open request carrying `params`.
|
||||
pub(crate) fn no_test_request(params: &Params, hmac_key: Option<&[u8]>) -> Vec<u8> {
|
||||
encode_request(
|
||||
RequestToEncode::Open {
|
||||
params,
|
||||
no_test: true,
|
||||
},
|
||||
hmac_key,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Encodes a close request for `token`.
|
||||
pub(crate) fn close_request(token: u64, hmac_key: Option<&[u8]>) -> Vec<u8> {
|
||||
encode_request(RequestToEncode::Close { token }, hmac_key).unwrap()
|
||||
}
|
||||
|
||||
/// Encodes a close request carrying arbitrary bytes after the token.
|
||||
///
|
||||
/// The decoder tolerates them, and this is the shape that proves it. The MAC
|
||||
/// covers the whole datagram, so it is recomputed over the final form rather
|
||||
/// than left behind by the encoder's shorter packet.
|
||||
pub(crate) fn close_request_with_trailing(
|
||||
token: u64,
|
||||
trailing: &[u8],
|
||||
hmac_key: Option<&[u8]>,
|
||||
) -> Vec<u8> {
|
||||
let mut packet = close_request(token, hmac_key);
|
||||
packet.extend_from_slice(trailing);
|
||||
if let Some(key) = hmac_key {
|
||||
irtt_proto::compute_hmac_in_place(key, &mut packet, hmac_field_offset()).unwrap();
|
||||
}
|
||||
packet
|
||||
}
|
||||
|
||||
/// Builds an open request around a raw parameter payload.
|
||||
///
|
||||
/// Used for payloads a compliant encoder cannot produce, such as a truncated
|
||||
/// varint or an out-of-range enum value.
|
||||
pub(crate) fn open_request_with_raw_params(payload: &[u8], hmac_key: Option<&[u8]>) -> Vec<u8> {
|
||||
let mut packet = irtt_proto::MAGIC.to_vec();
|
||||
packet.push(if hmac_key.is_some() {
|
||||
FLAG_OPEN | FLAG_HMAC
|
||||
} else {
|
||||
FLAG_OPEN
|
||||
});
|
||||
if hmac_key.is_some() {
|
||||
packet.extend_from_slice(&[0; irtt_proto::HMAC_SIZE]);
|
||||
}
|
||||
packet.extend_from_slice(payload);
|
||||
if let Some(key) = hmac_key {
|
||||
irtt_proto::compute_hmac_in_place(key, &mut packet, hmac_field_offset()).unwrap();
|
||||
}
|
||||
packet
|
||||
}
|
||||
|
||||
/// The authentication field sits immediately after the fixed header.
|
||||
fn hmac_field_offset() -> usize {
|
||||
PacketLayout::open_request(false).header_len()
|
||||
}
|
||||
|
||||
/// Encodes one integer parameter as `tag` + zigzag value.
|
||||
pub(crate) fn param_int(tag: u64, value: i64) -> Vec<u8> {
|
||||
let mut encoded = Vec::new();
|
||||
varint::encode_uvarint(tag, &mut encoded);
|
||||
varint::encode_varint(value, &mut encoded);
|
||||
encoded
|
||||
}
|
||||
|
||||
/// Encodes a `server_fill` parameter with an explicit declared length, so tests
|
||||
/// can declare a length the payload does not actually carry.
|
||||
pub(crate) fn param_server_fill(declared_len: u64, value: &[u8]) -> Vec<u8> {
|
||||
let mut encoded = Vec::new();
|
||||
varint::encode_uvarint(9, &mut encoded);
|
||||
varint::encode_uvarint(declared_len, &mut encoded);
|
||||
encoded.extend_from_slice(value);
|
||||
encoded
|
||||
}
|
||||
|
||||
/// Params a well-behaved client would request.
|
||||
pub(crate) fn client_params() -> Params {
|
||||
Params {
|
||||
protocol_version: 1,
|
||||
@@ -452,7 +77,6 @@ pub(crate) fn client_params() -> Params {
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes a reply the server produced, checking it against the server's key.
|
||||
pub(crate) fn decode_reply(packet: impl AsRef<[u8]>, hmac_key: Option<&[u8]>) -> OpenReply {
|
||||
decode_open_reply(packet.as_ref(), hmac_key).expect("server reply must decode")
|
||||
}
|
||||
@@ -468,12 +92,3 @@ pub(crate) fn expect_normal_open_reply(
|
||||
assert_eq!(reply.params.protocol_version, 1);
|
||||
reply
|
||||
}
|
||||
|
||||
pub(crate) fn expect_no_test_reply(packet: impl AsRef<[u8]>, hmac_key: Option<&[u8]>) -> OpenReply {
|
||||
let reply = decode_reply(packet, hmac_key);
|
||||
let expected =
|
||||
FLAG_OPEN | FLAG_REPLY | FLAG_CLOSE | if hmac_key.is_some() { FLAG_HMAC } else { 0 };
|
||||
assert_eq!(reply.flags, expected, "no-test reply flags");
|
||||
assert_eq!(reply.token, 0, "a no-test reply carries a zero token");
|
||||
reply
|
||||
}
|
||||
@@ -34,7 +34,6 @@ fn a_colliding_draw_is_discarded_and_never_overwrites_the_live_session() {
|
||||
core.handle_datagram(peer(), &request)
|
||||
.unwrap()
|
||||
.expect("first open must be answered");
|
||||
let first = core.session(TOKEN_A).cloned().expect("session must exist");
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &request)
|
||||
@@ -43,11 +42,6 @@ fn a_colliding_draw_is_discarded_and_never_overwrites_the_live_session() {
|
||||
|
||||
assert_eq!(expect_normal_open_reply(&packet, None).token, TOKEN_B);
|
||||
assert_eq!(core.session_count(), 2);
|
||||
assert_eq!(
|
||||
core.session(TOKEN_A),
|
||||
Some(&first),
|
||||
"the colliding token's session must be untouched"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -61,7 +55,6 @@ fn exhausting_the_retry_budget_is_an_internal_error_that_creates_nothing() {
|
||||
core.handle_datagram(peer(), &request)
|
||||
.unwrap()
|
||||
.expect("first open must be answered");
|
||||
let first = core.session(TOKEN_A).cloned().expect("session must exist");
|
||||
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &request),
|
||||
@@ -71,7 +64,6 @@ fn exhausting_the_retry_budget_is_an_internal_error_that_creates_nothing() {
|
||||
);
|
||||
|
||||
assert_eq!(core.session_count(), 1, "no session may be half-created");
|
||||
assert_eq!(core.session(TOKEN_A), Some(&first));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
//! Which traffic class the core asks for each reply to be sent with.
|
||||
//!
|
||||
//! The negotiated DSCP parameter is the **raw IPv4 TOS / IPv6 Traffic Class
|
||||
//! byte**, not a six-bit codepoint: the codepoint is its upper six bits and the
|
||||
//! low two are ECN. Nothing here shifts, masks or rounds it, and these tests
|
||||
//! exist to keep it that way.
|
||||
//!
|
||||
//! The clean specification's Section 20 settles the observable rules for values
|
||||
//! in `0..=255`: an echo reply carries its own session's value verbatim, the
|
||||
//! marking follows the session rather than the listener, and an open reply is
|
||||
//! unmarked even on a listener that has already sent a marked echo reply. The
|
||||
//! server-close case is covered where the maximum-duration close is tested, and
|
||||
//! the socket-level proof that these bytes reach the wire lives in the runtime's
|
||||
//! Linux tests.
|
||||
|
||||
use irtt_proto::Params;
|
||||
|
||||
use super::support::{
|
||||
client_params, core_with_tokens, echo_request, expect_echo_reply, expect_no_test_reply,
|
||||
expect_normal_open_reply, no_test_request, open_request, peer, unthrottled, ScriptedTokens,
|
||||
};
|
||||
|
||||
const TOKEN_A: u64 = 0x7896_b6ab_8771_5213;
|
||||
|
||||
/// A raw byte whose low two bits are set on purpose: ECN is part of the value,
|
||||
/// and a server rebuilding the byte from a codepoint would lose them.
|
||||
const RAW_TRAFFIC_CLASS: i64 = 0xbb;
|
||||
|
||||
#[test]
|
||||
fn an_open_reply_is_unmarked_whatever_the_session_negotiates() {
|
||||
let requested = Params {
|
||||
dscp: 0xb8,
|
||||
..client_params()
|
||||
};
|
||||
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
|
||||
|
||||
let reply = core
|
||||
.handle_datagram(peer(), &open_request(&requested, None))
|
||||
.unwrap()
|
||||
.expect("an ordinary open must be answered");
|
||||
|
||||
assert_eq!(
|
||||
expect_normal_open_reply(&reply, None).params.dscp,
|
||||
0xb8,
|
||||
"the negotiated value is returned to the client"
|
||||
);
|
||||
assert_eq!(
|
||||
reply.traffic_class(),
|
||||
0,
|
||||
"an open reply is unmarked however the session it creates is marked"
|
||||
);
|
||||
|
||||
let no_test = core
|
||||
.handle_datagram(peer(), &no_test_request(&requested, None))
|
||||
.unwrap()
|
||||
.expect("a no-test open must be answered");
|
||||
assert_eq!(expect_no_test_reply(&no_test, None).params.dscp, 0xb8);
|
||||
assert_eq!(
|
||||
no_test.traffic_class(),
|
||||
0,
|
||||
"a no-test reply is unmarked too"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_echo_reply_carries_the_sessions_raw_negotiated_byte() {
|
||||
let requested = Params {
|
||||
dscp: RAW_TRAFFIC_CLASS,
|
||||
..client_params()
|
||||
};
|
||||
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
|
||||
let open = core
|
||||
.handle_datagram(peer(), &open_request(&requested, None))
|
||||
.unwrap()
|
||||
.expect("the session-creating open must be answered");
|
||||
let negotiated = expect_normal_open_reply(&open, None).params;
|
||||
|
||||
let reply = core
|
||||
.handle_datagram(peer(), &echo_request(TOKEN_A, 0, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.expect("an admissible echo must be answered");
|
||||
|
||||
expect_echo_reply(&reply, &negotiated, None);
|
||||
assert_eq!(
|
||||
i64::from(reply.traffic_class()),
|
||||
RAW_TRAFFIC_CLASS,
|
||||
"the raw byte, unshifted and unmasked"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_negotiated_value_that_is_not_a_byte_is_transported_unmarked() {
|
||||
// `irtt-rs` policy, not observed behavior. The server deliberately accepts
|
||||
// a DSCP parameter outside `0..=255` and returns and stores it unchanged;
|
||||
// the clean specification records the reference host's handling of such
|
||||
// values as platform-specific and explicitly not a compatibility
|
||||
// requirement. A value that is not a byte cannot be a traffic-class byte,
|
||||
// so the transport asks for none — rather than wrapping −1 into 255,
|
||||
// truncating 256 to 0, or refusing a session the client can otherwise use.
|
||||
for dscp in [-1, 256, i64::MAX] {
|
||||
let requested = Params {
|
||||
dscp,
|
||||
..client_params()
|
||||
};
|
||||
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
|
||||
|
||||
let open = core
|
||||
.handle_datagram(peer(), &open_request(&requested, None))
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("an open requesting DSCP {dscp} must still be answered"));
|
||||
let negotiated = expect_normal_open_reply(&open, None).params;
|
||||
assert_eq!(negotiated.dscp, dscp, "the value is negotiated unchanged");
|
||||
assert_eq!(open.traffic_class(), 0);
|
||||
assert_eq!(
|
||||
core.session(TOKEN_A)
|
||||
.expect("the session must be live")
|
||||
.params()
|
||||
.dscp,
|
||||
dscp,
|
||||
"and stored unchanged"
|
||||
);
|
||||
|
||||
let reply = core
|
||||
.handle_datagram(peer(), &echo_request(TOKEN_A, 0, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("the session must still serve echoes with DSCP {dscp}"));
|
||||
assert_eq!(
|
||||
expect_echo_reply(&reply, &negotiated, None).recv_count,
|
||||
Some(1),
|
||||
"an unmarkable session is an ordinary session in every other way"
|
||||
);
|
||||
assert_eq!(reply.traffic_class(), 0, "transported unmarked");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
//! Durable server contracts observed through encoded requests and public replies.
|
||||
use std::{
|
||||
net::SocketAddr,
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use irtt_proto::{
|
||||
decode_echo_reply, decode_open_reply, encode_request, Clock, EchoReply, OpenReply, Params,
|
||||
ReceivedStats, RequestToEncode, StampAt, FLAG_CLOSE,
|
||||
};
|
||||
use irtt_server::{ServerConfig, ServerCore, TimestampAllowance};
|
||||
|
||||
fn peer() -> SocketAddr {
|
||||
"127.0.0.1:40000".parse().unwrap()
|
||||
}
|
||||
fn open_packet(params: &Params, no_test: bool, key: Option<&[u8]>) -> Vec<u8> {
|
||||
encode_request(RequestToEncode::Open { params, no_test }, key).unwrap()
|
||||
}
|
||||
fn open(server: &mut ServerCore, params: &Params) -> OpenReply {
|
||||
let reply = server
|
||||
.handle_datagram(peer(), &open_packet(params, false, None))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
decode_open_reply(reply.bytes(), None).unwrap()
|
||||
}
|
||||
fn echo(server: &mut ServerCore, opened: &OpenReply, sequence: u32) -> Option<EchoReply> {
|
||||
let request = encode_request(
|
||||
RequestToEncode::Echo {
|
||||
token: opened.token,
|
||||
sequence,
|
||||
params: &opened.params,
|
||||
payload: &[],
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
server
|
||||
.handle_datagram(peer(), &request)
|
||||
.unwrap()
|
||||
.map(|reply| decode_echo_reply(reply.bytes(), &opened.params, None).unwrap())
|
||||
}
|
||||
fn unthrottled() -> ServerConfig {
|
||||
ServerConfig::default().with_min_send_interval(Duration::ZERO)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_test_authenticates_negotiates_and_uses_no_session_capacity() {
|
||||
let key = b"no-test-contract";
|
||||
let mut server = ServerCore::new(
|
||||
ServerConfig::default()
|
||||
.with_hmac_key(key)
|
||||
.with_max_sessions(0)
|
||||
.with_max_packet_length(96)
|
||||
.with_min_send_interval(Duration::from_millis(50))
|
||||
.with_timestamp_allowance(TimestampAllowance::Single)
|
||||
.with_dscp_allowed(false),
|
||||
);
|
||||
let params = Params {
|
||||
protocol_version: 99,
|
||||
length: 128,
|
||||
dscp: 185,
|
||||
stamp_at: StampAt::Both,
|
||||
clock: Clock::Both,
|
||||
..Params::default()
|
||||
};
|
||||
let request = open_packet(¶ms, true, Some(key));
|
||||
assert!(server
|
||||
.handle_datagram(peer(), &open_packet(¶ms, true, None))
|
||||
.unwrap()
|
||||
.is_none());
|
||||
let mut bad_mac = request.clone();
|
||||
bad_mac[4] ^= 1;
|
||||
assert!(server.handle_datagram(peer(), &bad_mac).unwrap().is_none());
|
||||
let reply = server.handle_datagram(peer(), &request).unwrap().unwrap();
|
||||
assert_eq!(reply.traffic_class(), 0);
|
||||
let negotiated = decode_open_reply(reply.bytes(), Some(key)).unwrap();
|
||||
assert_eq!(negotiated.token, 0);
|
||||
assert_eq!(negotiated.params.protocol_version, 1);
|
||||
assert_ne!(negotiated.flags & FLAG_CLOSE, 0);
|
||||
assert_eq!(negotiated.params.length, 96);
|
||||
assert_eq!(negotiated.params.interval_ns, 50_000_000);
|
||||
assert_eq!(negotiated.params.stamp_at, StampAt::Midpoint);
|
||||
assert_eq!(negotiated.params.clock, Clock::Both);
|
||||
assert_eq!(negotiated.params.dscp, 0);
|
||||
assert_eq!(server.session_count(), 0);
|
||||
assert!(server
|
||||
.handle_datagram(peer(), &open_packet(¶ms, false, Some(key)))
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_test_refuses_unexecutable_layout_but_validates_after_restriction() {
|
||||
let params = Params {
|
||||
stamp_at: StampAt::Both,
|
||||
..Params::with_protocol_defaults()
|
||||
};
|
||||
let mut server = ServerCore::new(unthrottled());
|
||||
assert!(server
|
||||
.handle_datagram(peer(), &open_packet(¶ms, true, None))
|
||||
.unwrap()
|
||||
.is_none());
|
||||
let mut restricted =
|
||||
ServerCore::new(unthrottled().with_timestamp_allowance(TimestampAllowance::None));
|
||||
let reply = restricted
|
||||
.handle_datagram(peer(), &open_packet(¶ms, true, None))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
decode_open_reply(reply.bytes(), None)
|
||||
.unwrap()
|
||||
.params
|
||||
.stamp_at,
|
||||
StampAt::None
|
||||
);
|
||||
let rich = Params {
|
||||
received_stats: ReceivedStats::Both,
|
||||
stamp_at: StampAt::Both,
|
||||
clock: Clock::Both,
|
||||
..Params::with_protocol_defaults()
|
||||
};
|
||||
let mut too_small = ServerCore::new(unthrottled().with_max_packet_length(32));
|
||||
assert!(too_small
|
||||
.handle_datagram(peer(), &open_packet(&rich, true, None))
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert_eq!(too_small.session_count(), 0);
|
||||
let invalid = Params {
|
||||
interval_ns: -1,
|
||||
..Params::with_protocol_defaults()
|
||||
};
|
||||
assert!(server
|
||||
.handle_datagram(peer(), &open_packet(&invalid, true, None))
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert_eq!(server.session_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receive_statistics_follow_verified_gap_duplicate_reordering_and_wrap_vectors() {
|
||||
// SERVER_BEHAVIORAL_VECTORS.md Sections 1.2, 1.5, 1.6, 1.8 and 1.10.
|
||||
let vectors: &[&[(u32, u64)]] = &[
|
||||
&[(0, 1), (1, 3), (3, 13), (4, 27)],
|
||||
&[(0, 1), (1, 3), (1, 3), (2, 7)],
|
||||
&[(0, 1), (1, 3), (2, 7), (1, 1), (3, 5)],
|
||||
&[(0, 1), (63, 0x8000_0000_0000_0001)],
|
||||
&[(0, 1), (64, 1)],
|
||||
&[
|
||||
(u32::MAX - 2, 1),
|
||||
(u32::MAX - 1, 3),
|
||||
(u32::MAX, 7),
|
||||
(0, 15),
|
||||
(1, 31),
|
||||
],
|
||||
];
|
||||
for vector in vectors {
|
||||
let mut server = ServerCore::new(unthrottled());
|
||||
let opened = open(
|
||||
&mut server,
|
||||
&Params {
|
||||
received_stats: ReceivedStats::Both,
|
||||
..Params::with_protocol_defaults()
|
||||
},
|
||||
);
|
||||
for (index, &(sequence, window)) in vector.iter().enumerate() {
|
||||
let reply = echo(&mut server, &opened, sequence).unwrap();
|
||||
assert_eq!(reply.recv_count, Some(index as u32 + 1));
|
||||
assert_eq!(
|
||||
reply.recv_window,
|
||||
Some(window),
|
||||
"sequence {sequence} in {vector:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spent_nonzero_burst_refills_without_counting_dropped_echoes() {
|
||||
let refill = Duration::from_millis(100);
|
||||
let mut server = ServerCore::new(
|
||||
ServerConfig::default()
|
||||
.with_min_send_interval(refill)
|
||||
.with_burst_allowance(1)
|
||||
.with_idle_timeout(Duration::MAX),
|
||||
);
|
||||
let opened = open(
|
||||
&mut server,
|
||||
&Params {
|
||||
received_stats: ReceivedStats::Both,
|
||||
..Params::with_protocol_defaults()
|
||||
},
|
||||
);
|
||||
let mut count = echo(&mut server, &opened, 0).unwrap().recv_count.unwrap();
|
||||
// Observe actual refusal rather than assuming two calls fit a timing window.
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while let Some(reply) = echo(&mut server, &opened, 0) {
|
||||
count = reply.recv_count.unwrap();
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"could not observe a spent burst within two seconds"
|
||||
);
|
||||
}
|
||||
thread::sleep(refill); // Oversleep only replenishes more; idle expiry is disabled in practice.
|
||||
let reply = echo(&mut server, &opened, 1).unwrap();
|
||||
assert_eq!(reply.recv_count, Some(count + 1));
|
||||
assert_eq!(reply.recv_window, Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nonzero_idle_deadline_reclaims_an_unused_session_before_another_open() {
|
||||
let idle = Duration::from_millis(10);
|
||||
let mut server = ServerCore::new(unthrottled().with_max_sessions(1).with_idle_timeout(idle));
|
||||
let params = Params::with_protocol_defaults();
|
||||
let old = open(&mut server, ¶ms);
|
||||
thread::sleep(idle); // Any oversleep still requires silent expiry and capacity recovery.
|
||||
assert!(echo(&mut server, &old, 0).is_none());
|
||||
assert_eq!(server.session_count(), 0);
|
||||
open(&mut server, ¶ms);
|
||||
assert_eq!(server.session_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maximum_duration_starts_at_the_first_served_echo_and_closes_with_its_reply() {
|
||||
let maximum = Duration::from_millis(1);
|
||||
let mut server = ServerCore::new(
|
||||
unthrottled()
|
||||
.with_idle_timeout(Duration::MAX)
|
||||
.with_max_test_duration(maximum),
|
||||
);
|
||||
let opened = open(
|
||||
&mut server,
|
||||
&Params {
|
||||
received_stats: ReceivedStats::Both,
|
||||
stamp_at: StampAt::Both,
|
||||
clock: Clock::Both,
|
||||
dscp: 185,
|
||||
..Params::with_protocol_defaults()
|
||||
},
|
||||
);
|
||||
assert_eq!(opened.params.duration_ns, 1_000_000);
|
||||
let elapsed_deadline = maximum + Duration::from_secs(2);
|
||||
thread::sleep(elapsed_deadline);
|
||||
let first = echo(&mut server, &opened, 0).unwrap();
|
||||
assert_eq!(
|
||||
first.flags & FLAG_CLOSE,
|
||||
0,
|
||||
"OPEN must not start the maximum-duration clock"
|
||||
);
|
||||
thread::sleep(elapsed_deadline); // Oversleep still requires the next served echo to close.
|
||||
let request = encode_request(
|
||||
RequestToEncode::Echo {
|
||||
token: opened.token,
|
||||
sequence: 1,
|
||||
params: &opened.params,
|
||||
payload: &[],
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let reply = server.handle_datagram(peer(), &request).unwrap().unwrap();
|
||||
assert_eq!(reply.traffic_class(), 185);
|
||||
let closed = decode_echo_reply(reply.bytes(), &opened.params, None).unwrap();
|
||||
assert_ne!(closed.flags & FLAG_CLOSE, 0);
|
||||
assert_eq!(closed.sequence, 1);
|
||||
assert_eq!(closed.recv_count, Some(2));
|
||||
assert_eq!(closed.recv_window, Some(3));
|
||||
assert!(closed.timestamps.recv_wall.is_some());
|
||||
assert!(closed.timestamps.send_mono.is_some());
|
||||
assert_eq!(server.session_count(), 0);
|
||||
assert!(echo(&mut server, &opened, 2).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negotiated_timestamp_modes_emit_only_the_selected_fields_in_order() {
|
||||
for clock in [Clock::Wall, Clock::Monotonic, Clock::Both] {
|
||||
for placement in [
|
||||
StampAt::None,
|
||||
StampAt::Receive,
|
||||
StampAt::Send,
|
||||
StampAt::Both,
|
||||
StampAt::Midpoint,
|
||||
] {
|
||||
let mut server = ServerCore::new(unthrottled());
|
||||
let opened = open(
|
||||
&mut server,
|
||||
&Params {
|
||||
stamp_at: placement,
|
||||
clock,
|
||||
..Params::with_protocol_defaults()
|
||||
},
|
||||
);
|
||||
let t = echo(&mut server, &opened, 0).unwrap().timestamps;
|
||||
let wall = clock != Clock::Monotonic;
|
||||
let mono = clock != Clock::Wall;
|
||||
let recv = matches!(placement, StampAt::Receive | StampAt::Both);
|
||||
let send = matches!(placement, StampAt::Send | StampAt::Both);
|
||||
let midpoint = placement == StampAt::Midpoint;
|
||||
assert_eq!(t.recv_wall.is_some(), wall && recv);
|
||||
assert_eq!(t.recv_mono.is_some(), mono && recv);
|
||||
assert_eq!(t.send_wall.is_some(), wall && send);
|
||||
assert_eq!(t.send_mono.is_some(), mono && send);
|
||||
assert_eq!(t.midpoint_wall.is_some(), wall && midpoint);
|
||||
assert_eq!(t.midpoint_mono.is_some(), mono && midpoint);
|
||||
if let (Some(recv), Some(send)) = (t.recv_wall, t.send_wall) {
|
||||
assert!(recv <= send);
|
||||
}
|
||||
if let (Some(recv), Some(send)) = (t.recv_mono, t.send_mono) {
|
||||
assert!(0 <= recv && recv <= send);
|
||||
let next = echo(&mut server, &opened, 1).unwrap().timestamps;
|
||||
assert!(
|
||||
next.recv_mono.unwrap() >= send,
|
||||
"monotonic origin must remain stable"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_close_waits_for_rate_allowance_after_the_deadline() {
|
||||
let refill = Duration::from_secs(4);
|
||||
let maximum = Duration::from_millis(1);
|
||||
for _ in 0..3 {
|
||||
let mut server = ServerCore::new(
|
||||
ServerConfig::default()
|
||||
.with_min_send_interval(refill)
|
||||
.with_burst_allowance(1)
|
||||
.with_idle_timeout(Duration::MAX)
|
||||
.with_max_test_duration(maximum),
|
||||
);
|
||||
let started = Instant::now();
|
||||
let opened = open(
|
||||
&mut server,
|
||||
&Params {
|
||||
received_stats: ReceivedStats::Both,
|
||||
..Params::with_protocol_defaults()
|
||||
},
|
||||
);
|
||||
let first = echo(&mut server, &opened, 0).unwrap();
|
||||
assert_eq!(first.flags & FLAG_CLOSE, 0);
|
||||
thread::sleep(maximum + Duration::from_secs(2));
|
||||
let refused = echo(&mut server, &opened, 63);
|
||||
// Include open/setup and the request itself in the elapsed bound. If the
|
||||
// scheduler carried us past refill, the reply may legitimately close.
|
||||
if started.elapsed() >= refill {
|
||||
continue;
|
||||
}
|
||||
assert!(
|
||||
refused.is_none(),
|
||||
"exhausted allowance must suppress the deadline-crossing reply"
|
||||
);
|
||||
assert_eq!(
|
||||
server.session_count(),
|
||||
1,
|
||||
"rate refusal must not release the session"
|
||||
);
|
||||
thread::sleep(refill);
|
||||
let closed = echo(&mut server, &opened, 1).unwrap();
|
||||
assert_ne!(closed.flags & FLAG_CLOSE, 0);
|
||||
assert_eq!(closed.sequence, 1);
|
||||
assert_eq!(closed.recv_count, Some(2));
|
||||
assert_eq!(closed.recv_window, Some(3));
|
||||
assert_eq!(server.session_count(), 0);
|
||||
return;
|
||||
}
|
||||
panic!("scheduler crossed the refill boundary in all three fresh sessions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_server_resource_limits_remain_bounded() {
|
||||
let config = ServerConfig::default();
|
||||
// Upper resource budgets, not an exact default or a comparison against
|
||||
// the same constants used by the implementation. Smaller bounds are fine.
|
||||
assert!((1..=1024).contains(&config.max_sessions()));
|
||||
assert!((1..=65_507).contains(&config.max_packet_length()));
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
use std::{net::Ipv4Addr, time::Duration};
|
||||
|
||||
use irtt_proto::{
|
||||
decode_echo_reply, decode_open_reply, encode_request, Clock, Params, ReceivedStats,
|
||||
RequestToEncode, StampAt,
|
||||
};
|
||||
use tokio::{net::UdpSocket, sync::oneshot, time::timeout};
|
||||
|
||||
use irtt_server::{Server, ServerConfig, ServerRuntimeError};
|
||||
use std::{io, net::SocketAddr};
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn bind_reports_local_address_and_shutdown_stops_run() {
|
||||
let requested = SocketAddr::from((Ipv4Addr::LOCALHOST, 0));
|
||||
let mut server = Server::bind(requested, ServerConfig::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let bound = server.local_addr().unwrap();
|
||||
assert_eq!(bound.ip(), requested.ip());
|
||||
assert_ne!(bound.port(), 0);
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
||||
shutdown_tx.send(()).unwrap();
|
||||
server
|
||||
.run(async {
|
||||
let _ = shutdown_rx.await;
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn ipv4_open_echo_close_crosses_a_real_udp_socket() {
|
||||
exercise_udp_path(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))).await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn ipv6_open_echo_close_when_loopback_is_available() {
|
||||
let addr = "[::1]:0".parse().unwrap();
|
||||
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;
|
||||
}
|
||||
Err(error) => panic!("unexpected IPv6 loopback bind failure: {error}"),
|
||||
};
|
||||
exercise_bound_server(server).await;
|
||||
}
|
||||
|
||||
async fn exercise_udp_path(addr: SocketAddr) {
|
||||
let server = Server::bind(addr, unthrottled()).await.unwrap();
|
||||
exercise_bound_server(server).await;
|
||||
}
|
||||
|
||||
async fn exercise_bound_server(mut server: Server) {
|
||||
let server_addr = server.local_addr().unwrap();
|
||||
let client_bind = match server_addr {
|
||||
SocketAddr::V4(_) => "127.0.0.1:0",
|
||||
SocketAddr::V6(_) => "[::1]:0",
|
||||
};
|
||||
let client = UdpSocket::bind(client_bind).await.unwrap();
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
||||
let server_task = tokio::spawn(async move {
|
||||
server
|
||||
.run(async {
|
||||
let _ = shutdown_rx.await;
|
||||
})
|
||||
.await
|
||||
});
|
||||
|
||||
let requested = Params {
|
||||
protocol_version: 1,
|
||||
duration_ns: 1_000_000_000,
|
||||
interval_ns: 100_000_000,
|
||||
received_stats: ReceivedStats::Both,
|
||||
stamp_at: StampAt::Both,
|
||||
clock: Clock::Both,
|
||||
..Params::default()
|
||||
};
|
||||
let open = encode_request(
|
||||
RequestToEncode::Open {
|
||||
params: &requested,
|
||||
no_test: false,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
client.send_to(&open, server_addr).await.unwrap();
|
||||
|
||||
let mut buffer = vec![0; 65_536];
|
||||
let (len, reply_source) = timeout(Duration::from_secs(1), client.recv_from(&mut buffer))
|
||||
.await
|
||||
.expect("OPEN reply timeout")
|
||||
.unwrap();
|
||||
assert_eq!(reply_source, server_addr);
|
||||
let open_reply = decode_open_reply(&buffer[..len], None).unwrap();
|
||||
assert_ne!(open_reply.token, 0);
|
||||
|
||||
let echo = encode_request(
|
||||
RequestToEncode::Echo {
|
||||
token: open_reply.token,
|
||||
sequence: 0,
|
||||
params: &open_reply.params,
|
||||
payload: &[],
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
client.send_to(&echo, server_addr).await.unwrap();
|
||||
let (len, reply_source) = timeout(Duration::from_secs(1), client.recv_from(&mut buffer))
|
||||
.await
|
||||
.expect("ECHO reply timeout")
|
||||
.unwrap();
|
||||
assert_eq!(reply_source, server_addr);
|
||||
let echo_reply = decode_echo_reply(&buffer[..len], &open_reply.params, None).unwrap();
|
||||
assert_eq!(echo_reply.token, open_reply.token);
|
||||
assert_eq!(echo_reply.sequence, 0);
|
||||
assert_eq!(echo_reply.recv_count, Some(1));
|
||||
assert_eq!(echo_reply.recv_window, Some(1));
|
||||
|
||||
let close = encode_request(
|
||||
RequestToEncode::Close {
|
||||
token: open_reply.token,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
client.send_to(&close, server_addr).await.unwrap();
|
||||
client.send_to(&echo, server_addr).await.unwrap();
|
||||
assert!(
|
||||
timeout(Duration::from_millis(100), client.recv_from(&mut buffer))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
shutdown_tx.send(()).unwrap();
|
||||
server_task.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
fn unthrottled() -> ServerConfig {
|
||||
ServerConfig::default().with_min_send_interval(Duration::ZERO)
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "freebsd")))]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn wildcard_construction_is_refused_without_reply_source_selection() {
|
||||
let addr = "0.0.0.0:0".parse().unwrap();
|
||||
assert!(matches!(
|
||||
Server::bind(addr, unthrottled()).await,
|
||||
Err(ServerRuntimeError::WildcardSourceSelectionUnsupported { .. })
|
||||
));
|
||||
}
|
||||
|
||||
// A current-thread runtime cannot receive while this test holds its thread.
|
||||
// Queue real packets before delaying the server: no synthetic ancillary data or
|
||||
// transport hooks. Observed queue timing determines whether kernel wall time
|
||||
// is plausible under the one-second bound, including scheduler overshoot. Monotonic and
|
||||
// midpoint timestamps must always remain at the later userspace observation.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn queued_echoes_select_kernel_receive_wall_only_when_plausible() {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let ns = || {
|
||||
i64::try_from(
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos(),
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
for (placement, delay) in [
|
||||
(StampAt::Both, Duration::from_millis(100)),
|
||||
(StampAt::Both, Duration::from_millis(1100)),
|
||||
(StampAt::Midpoint, Duration::from_millis(100)),
|
||||
] {
|
||||
let mut server = Server::bind("127.0.0.1:0".parse().unwrap(), unthrottled())
|
||||
.await
|
||||
.unwrap();
|
||||
let addr = server.local_addr().unwrap();
|
||||
let client = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
client.connect(addr).await.unwrap();
|
||||
let (stop_tx, stop_rx) = oneshot::channel();
|
||||
let run = tokio::spawn(async move {
|
||||
server
|
||||
.run(async {
|
||||
let _ = stop_rx.await;
|
||||
})
|
||||
.await
|
||||
});
|
||||
let params = Params {
|
||||
stamp_at: placement,
|
||||
clock: Clock::Both,
|
||||
received_stats: ReceivedStats::Both,
|
||||
..Params::with_protocol_defaults()
|
||||
};
|
||||
let open = encode_request(
|
||||
RequestToEncode::Open {
|
||||
params: ¶ms,
|
||||
no_test: false,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
client.send(&open).await.unwrap();
|
||||
let mut buffer = [0; 2048];
|
||||
let len = timeout(Duration::from_secs(2), client.recv(&mut buffer))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let opened = decode_open_reply(&buffer[..len], None).unwrap();
|
||||
let echo = encode_request(
|
||||
RequestToEncode::Echo {
|
||||
token: opened.token,
|
||||
sequence: 0,
|
||||
params: &opened.params,
|
||||
payload: &[],
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
client.writable().await.unwrap();
|
||||
let before_send = ns();
|
||||
client.try_send(&echo).unwrap(); // No await from submission through resume marker.
|
||||
let queued_by = ns();
|
||||
std::thread::sleep(delay);
|
||||
let resumed_at = ns();
|
||||
let len = timeout(Duration::from_secs(2), client.recv(&mut buffer))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let reply = decode_echo_reply(&buffer[..len], &opened.params, None).unwrap();
|
||||
assert_eq!(reply.recv_count, Some(1));
|
||||
assert_eq!(reply.recv_window, Some(1));
|
||||
let timestamps = reply.timestamps;
|
||||
if placement == StampAt::Both {
|
||||
let receive = timestamps.recv_wall.unwrap();
|
||||
let send = timestamps.send_wall.unwrap();
|
||||
let kernel_endpoint = (before_send..=queued_by).contains(&receive);
|
||||
let userspace_endpoint = (resumed_at..=send).contains(&receive);
|
||||
// Arrival lies in [before_send, queued_by], and core observation
|
||||
// lies in [resumed_at, send]. Only demand one selection where these
|
||||
// observed bounds put the whole lag interval on one side of 1 s.
|
||||
// Scheduler overshoot can legitimately change the expected path.
|
||||
if send - before_send <= 1_000_000_000 {
|
||||
assert!(
|
||||
kernel_endpoint,
|
||||
"plausible arrival must select kernel wall time"
|
||||
);
|
||||
} else if resumed_at - queued_by > 1_000_000_000 {
|
||||
assert!(
|
||||
userspace_endpoint,
|
||||
"stale arrival must fall back to userspace"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
kernel_endpoint || userspace_endpoint,
|
||||
"boundary observation must identify a real endpoint"
|
||||
);
|
||||
}
|
||||
if kernel_endpoint {
|
||||
let wall_elapsed = send - receive;
|
||||
let mono_elapsed = timestamps.send_mono.unwrap() - timestamps.recv_mono.unwrap();
|
||||
assert!(
|
||||
wall_elapsed - mono_elapsed >= 50_000_000,
|
||||
"queue delay must not move the monotonic endpoint"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
assert!(
|
||||
timestamps.midpoint_wall.unwrap() >= resumed_at,
|
||||
"midpoint must use userspace time"
|
||||
);
|
||||
}
|
||||
stop_tx.send(()).unwrap();
|
||||
run.await.unwrap().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -465,3 +465,11 @@ where
|
||||
.await
|
||||
.expect("the test exceeded its bounded runtime");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn a_set_serving_nothing_is_refused() {
|
||||
assert!(matches!(
|
||||
ServerSet::bind([], ServerConfig::default()).await,
|
||||
Err(ServerSetError::NoListeners)
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
use std::{
|
||||
net::{SocketAddr, SocketAddrV6},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use irtt_proto::{decode_echo_reply, decode_open_reply, encode_request, Params, RequestToEncode};
|
||||
use irtt_server::{ServerConfig, ServerCore};
|
||||
|
||||
// Admission, authentication, endpoint ownership and capacity are checked through
|
||||
// the public packet interface, with actual OS-generated session tokens.
|
||||
#[test]
|
||||
fn authenticated_sessions_are_bounded_and_owned_by_the_opening_endpoint() {
|
||||
let key = b"session-behavior";
|
||||
let peer: SocketAddr = "127.0.0.1:40000".parse().unwrap();
|
||||
let other: SocketAddr = "127.0.0.1:40001".parse().unwrap();
|
||||
let mut server = ServerCore::new(
|
||||
ServerConfig::default()
|
||||
.with_hmac_key(key)
|
||||
.with_max_sessions(1)
|
||||
.with_min_send_interval(Duration::ZERO),
|
||||
);
|
||||
let params = Params {
|
||||
received_stats: irtt_proto::ReceivedStats::Both,
|
||||
..Params::with_protocol_defaults()
|
||||
};
|
||||
let open = encode_request(
|
||||
RequestToEncode::Open {
|
||||
params: ¶ms,
|
||||
no_test: false,
|
||||
},
|
||||
Some(key),
|
||||
)
|
||||
.unwrap();
|
||||
let mut unkeyed = ServerCore::new(ServerConfig::default());
|
||||
assert!(unkeyed.handle_datagram(peer, &open).unwrap().is_none());
|
||||
assert_eq!(unkeyed.session_count(), 0);
|
||||
let unauthenticated = encode_request(
|
||||
RequestToEncode::Open {
|
||||
params: ¶ms,
|
||||
no_test: false,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
for packet in [
|
||||
&[][..],
|
||||
&[0; 16][..],
|
||||
&open[..open.len() - 1],
|
||||
&unauthenticated,
|
||||
] {
|
||||
assert!(server.handle_datagram(peer, packet).unwrap().is_none());
|
||||
assert_eq!(server.session_count(), 0);
|
||||
}
|
||||
let reply = server.handle_datagram(peer, &open).unwrap().unwrap();
|
||||
let opened = decode_open_reply(reply.bytes(), Some(key)).unwrap();
|
||||
assert_ne!(opened.token, 0);
|
||||
assert!(server.handle_datagram(other, &open).unwrap().is_none());
|
||||
assert_eq!(server.session_count(), 1);
|
||||
let echo = encode_request(
|
||||
RequestToEncode::Echo {
|
||||
token: opened.token,
|
||||
sequence: 0,
|
||||
params: &opened.params,
|
||||
payload: &[],
|
||||
},
|
||||
Some(key),
|
||||
)
|
||||
.unwrap();
|
||||
let close = encode_request(
|
||||
RequestToEncode::Close {
|
||||
token: opened.token,
|
||||
},
|
||||
Some(key),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(server.handle_datagram(other, &echo).unwrap().is_none());
|
||||
assert!(server.handle_datagram(other, &close).unwrap().is_none());
|
||||
let mut corrupted = echo.clone();
|
||||
corrupted[4] ^= 1;
|
||||
assert!(server.handle_datagram(peer, &corrupted).unwrap().is_none());
|
||||
let reply = server.handle_datagram(peer, &echo).unwrap().unwrap();
|
||||
let echoed = decode_echo_reply(reply.bytes(), &opened.params, Some(key)).unwrap();
|
||||
assert_eq!(echoed.sequence, 0);
|
||||
assert_eq!(echoed.recv_count, Some(1));
|
||||
assert!(server.handle_datagram(peer, &close).unwrap().is_none());
|
||||
assert_eq!(server.session_count(), 0);
|
||||
assert!(server.handle_datagram(peer, &echo).unwrap().is_none());
|
||||
assert!(server.handle_datagram(other, &open).unwrap().is_some());
|
||||
}
|
||||
|
||||
// Zero allowance and zero idle lifetime expose the boundary behavior directly,
|
||||
// without scripting clock reads or inspecting session/rate representations.
|
||||
#[test]
|
||||
fn zero_burst_and_zero_idle_timeout_silently_refuse_echoes() {
|
||||
let peer: SocketAddr = "127.0.0.1:40000".parse().unwrap();
|
||||
for config in [
|
||||
ServerConfig::default().with_burst_allowance(0),
|
||||
ServerConfig::default().with_idle_timeout(Duration::ZERO),
|
||||
] {
|
||||
let mut server = ServerCore::new(config);
|
||||
let params = Params::with_protocol_defaults();
|
||||
let open = encode_request(
|
||||
RequestToEncode::Open {
|
||||
params: ¶ms,
|
||||
no_test: false,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let reply = server.handle_datagram(peer, &open).unwrap().unwrap();
|
||||
let opened = decode_open_reply(reply.bytes(), None).unwrap();
|
||||
let echo = encode_request(
|
||||
RequestToEncode::Echo {
|
||||
token: opened.token,
|
||||
sequence: 0,
|
||||
params: &opened.params,
|
||||
payload: &[],
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(server.handle_datagram(peer, &echo).unwrap().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv6_flow_labels_share_a_session_but_zones_do_not() {
|
||||
let endpoint = |flow, scope| {
|
||||
SocketAddr::V6(SocketAddrV6::new(
|
||||
"fe80::1234".parse().unwrap(),
|
||||
40000,
|
||||
flow,
|
||||
scope,
|
||||
))
|
||||
};
|
||||
let mut server =
|
||||
ServerCore::new(ServerConfig::default().with_min_send_interval(Duration::ZERO));
|
||||
let params = Params {
|
||||
received_stats: irtt_proto::ReceivedStats::Both,
|
||||
..Params::with_protocol_defaults()
|
||||
};
|
||||
let packet = encode_request(
|
||||
RequestToEncode::Open {
|
||||
params: ¶ms,
|
||||
no_test: false,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let reply = server
|
||||
.handle_datagram(endpoint(1, 2), &packet)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let opened = decode_open_reply(reply.bytes(), None).unwrap();
|
||||
let echo = encode_request(
|
||||
RequestToEncode::Echo {
|
||||
token: opened.token,
|
||||
sequence: 0,
|
||||
params: &opened.params,
|
||||
payload: &[],
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let close = encode_request(
|
||||
RequestToEncode::Close {
|
||||
token: opened.token,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(server
|
||||
.handle_datagram(endpoint(1, 3), &echo)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert!(server
|
||||
.handle_datagram(endpoint(1, 3), &close)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert_eq!(server.session_count(), 1);
|
||||
let reply = server
|
||||
.handle_datagram(endpoint(99, 2), &echo)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
decode_echo_reply(reply.bytes(), &opened.params, None)
|
||||
.unwrap()
|
||||
.recv_count,
|
||||
Some(1)
|
||||
);
|
||||
assert!(server
|
||||
.handle_datagram(endpoint(99, 2), &close)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert_eq!(server.session_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_authenticated_echo_does_not_advance_session_state() {
|
||||
let key = b"oversized-echo-test";
|
||||
let peer = "127.0.0.1:40000".parse().unwrap();
|
||||
let mut server = ServerCore::new(
|
||||
ServerConfig::default()
|
||||
.with_hmac_key(key)
|
||||
.with_max_packet_length(96)
|
||||
.with_burst_allowance(1)
|
||||
.with_min_send_interval(Duration::from_secs(60))
|
||||
.with_max_test_duration(Duration::from_millis(1))
|
||||
.with_idle_timeout(Duration::MAX),
|
||||
);
|
||||
let params = Params {
|
||||
length: 96,
|
||||
received_stats: irtt_proto::ReceivedStats::Both,
|
||||
..Params::with_protocol_defaults()
|
||||
};
|
||||
let open = encode_request(
|
||||
RequestToEncode::Open {
|
||||
params: ¶ms,
|
||||
no_test: false,
|
||||
},
|
||||
Some(key),
|
||||
)
|
||||
.unwrap();
|
||||
let reply = server.handle_datagram(peer, &open).unwrap().unwrap();
|
||||
let opened = decode_open_reply(reply.bytes(), Some(key)).unwrap();
|
||||
let oversized_params = Params {
|
||||
length: 128,
|
||||
..opened.params.clone()
|
||||
};
|
||||
let oversized = encode_request(
|
||||
RequestToEncode::Echo {
|
||||
token: opened.token,
|
||||
sequence: 63,
|
||||
params: &oversized_params,
|
||||
payload: &[],
|
||||
},
|
||||
Some(key),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(oversized.len() > 96);
|
||||
assert!(server.handle_datagram(peer, &oversized).unwrap().is_none());
|
||||
// If the oversized request started maximum duration, the first valid
|
||||
// echo would now close. Oversleep preserves this distinction.
|
||||
std::thread::sleep(Duration::from_millis(2001));
|
||||
let valid = encode_request(
|
||||
RequestToEncode::Echo {
|
||||
token: opened.token,
|
||||
sequence: 0,
|
||||
params: &opened.params,
|
||||
payload: &[],
|
||||
},
|
||||
Some(key),
|
||||
)
|
||||
.unwrap();
|
||||
let reply = server
|
||||
.handle_datagram(peer, &valid)
|
||||
.unwrap()
|
||||
.expect("oversized request must not consume allowance");
|
||||
let reply = decode_echo_reply(reply.bytes(), &opened.params, Some(key)).unwrap();
|
||||
assert_eq!(reply.flags & irtt_proto::FLAG_CLOSE, 0);
|
||||
assert_eq!(reply.recv_count, Some(1));
|
||||
assert_eq!(reply.recv_window, Some(1));
|
||||
assert_eq!(server.session_count(), 1);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Observe the emitted IP byte, independently of protocol negotiation.
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
use std::{io::IoSliceMut, net::UdpSocket as StdSocket, os::fd::AsRawFd, time::Duration};
|
||||
|
||||
use irtt_proto::{
|
||||
decode_echo_reply, decode_open_reply, encode_request, OpenReply, Params, RequestToEncode,
|
||||
FLAG_CLOSE,
|
||||
};
|
||||
use irtt_server::{Server, ServerConfig};
|
||||
use nix::sys::socket::{recvmsg, ControlMessageOwned, MsgFlags};
|
||||
use tokio::{net::UdpSocket, sync::oneshot, time::timeout};
|
||||
|
||||
async fn exchange(client: &UdpSocket, request: &[u8], expected: u8) -> Vec<u8> {
|
||||
client.send(request).await.unwrap();
|
||||
loop {
|
||||
client.readable().await.unwrap();
|
||||
let packet = client.try_io(tokio::io::Interest::READABLE, || {
|
||||
let mut bytes = [0; 2048];
|
||||
let mut control = nix::cmsg_space!(u8);
|
||||
let mut iov = [IoSliceMut::new(&mut bytes)];
|
||||
let msg = recvmsg::<()>(
|
||||
client.as_raw_fd(),
|
||||
&mut iov,
|
||||
Some(&mut control),
|
||||
MsgFlags::MSG_DONTWAIT,
|
||||
)?;
|
||||
let size = msg.bytes;
|
||||
let class = msg.cmsgs().unwrap().find_map(|message| match message {
|
||||
ControlMessageOwned::Ipv4Tos(class) => Some(class),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(class, Some(expected));
|
||||
Ok(bytes[..size].to_vec())
|
||||
});
|
||||
match packet {
|
||||
Ok(packet) => return packet,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {}
|
||||
Err(error) => panic!("receive failed: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn echo(opened: &OpenReply, sequence: u32) -> Vec<u8> {
|
||||
encode_request(
|
||||
RequestToEncode::Echo {
|
||||
token: opened.token,
|
||||
sequence,
|
||||
params: &opened.params,
|
||||
payload: &[],
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn every_open_echo_and_server_close_applies_its_own_raw_traffic_class() {
|
||||
timeout(Duration::from_secs(8), async {
|
||||
let maximum = Duration::from_millis(1);
|
||||
let mut server = Server::bind(
|
||||
"127.0.0.1:0".parse().unwrap(),
|
||||
ServerConfig::default()
|
||||
.with_min_send_interval(Duration::ZERO)
|
||||
.with_idle_timeout(Duration::MAX)
|
||||
.with_max_test_duration(maximum),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let addr = server.local_addr().unwrap();
|
||||
let (shutdown, stopped) = oneshot::channel();
|
||||
let task = tokio::spawn(async move {
|
||||
server
|
||||
.run(async {
|
||||
let _ = stopped.await;
|
||||
})
|
||||
.await
|
||||
});
|
||||
let socket = StdSocket::bind("127.0.0.1:0").unwrap();
|
||||
socket2::SockRef::from(&socket)
|
||||
.set_recv_tos_v4(true)
|
||||
.unwrap();
|
||||
socket.set_nonblocking(true).unwrap();
|
||||
let client = UdpSocket::from_std(socket).unwrap();
|
||||
client.connect(addr).await.unwrap();
|
||||
let mut sessions = Vec::new();
|
||||
// Each new OPEN follows a prior ECHO. The first raw byte also includes
|
||||
// ECN bits: neither a codepoint shift nor masking would preserve it.
|
||||
for (requested, class) in [(185, 185), (32, 32), (0, 0), (256, 0), (-1, 0)] {
|
||||
let params = Params {
|
||||
dscp: requested,
|
||||
..Params::with_protocol_defaults()
|
||||
};
|
||||
let request = encode_request(
|
||||
RequestToEncode::Open {
|
||||
params: ¶ms,
|
||||
no_test: false,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let packet = exchange(&client, &request, 0).await;
|
||||
let opened = decode_open_reply(&packet, None).unwrap();
|
||||
assert_eq!(opened.params.dscp, requested);
|
||||
let packet = exchange(&client, &echo(&opened, 0), class).await;
|
||||
assert_eq!(
|
||||
decode_echo_reply(&packet, &opened.params, None)
|
||||
.unwrap()
|
||||
.flags
|
||||
& FLAG_CLOSE,
|
||||
0
|
||||
);
|
||||
sessions.push((opened, class));
|
||||
}
|
||||
// Every session has now served its first echo. Waiting past all duration
|
||||
// deadlines is safe under oversleep; idle expiry cannot intervene.
|
||||
tokio::time::sleep(maximum + Duration::from_secs(2)).await;
|
||||
for (opened, class) in sessions {
|
||||
let packet = exchange(&client, &echo(&opened, 1), class).await;
|
||||
assert_ne!(
|
||||
decode_echo_reply(&packet, &opened.params, None)
|
||||
.unwrap()
|
||||
.flags
|
||||
& FLAG_CLOSE,
|
||||
0
|
||||
);
|
||||
}
|
||||
shutdown.send(()).unwrap();
|
||||
task.await.unwrap().unwrap();
|
||||
})
|
||||
.await
|
||||
.expect("marked runtime exchanges did not complete");
|
||||
}
|
||||
Reference in new issue
Block a user