test: strengthen durable behavior coverage after review

This commit is contained in:
Nils Andreas Svee committed 2026-10-02 15:14:05 +07:00
1 parent 3290a7508a
commit e115b96f21
14 files changed
+1063 -85

No files matched your search

+19 -13
View File
@@ -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
@@ -0,0 +1,318 @@
//! 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(&params, true, Some(key));
assert!(server
.handle_datagram(peer(), &open_packet(&params, 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(&params, 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(&params, true, None))
.unwrap()
.is_none());
let mut restricted =
ServerCore::new(unthrottled().with_timestamp_allowance(TimestampAllowance::None));
let reply = restricted
.handle_datagram(peer(), &open_packet(&params, 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, &params);
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, &params);
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"
);
}
}
}
}
+10
View File
@@ -145,3 +145,13 @@ async fn exercise_bound_server(mut server: Server) {
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 { .. })
));
}
+133
View File
@@ -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: &params,
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");
}