feat(server): generate echo payload fill

This commit is contained in:
Nils Andreas Svee committed 2026-08-14 12:58:30 +07:00
1 parent 36500e1ea0
commit e4239f45c5
12 files changed
+721 -41

No files matched your search

+13 -3
View File
@@ -56,9 +56,19 @@ pub const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
/// Configuration for a [`ServerCore`](crate::ServerCore).
///
/// Fields are private and set through consuming builder methods, so later
/// slices can add configuration without breaking construction. The remaining
/// parameter restriction knobs — the timestamp allowance, DSCP policy and the
/// server-fill allow-list — arrive with the slices that implement them.
/// slices can add configuration without breaking construction. Optional
/// timestamp-allowance and DSCP restriction controls would arrive with the
/// slices that implement them.
///
/// **Server fill is deliberately not configurable.** The policy is fixed and
/// safe: every valid descriptor is honored, one that cannot be parsed falls back
/// to `pattern:69727474`, and a client expressing no preference is served that
/// same default. There is no allow-list to configure, because refusing a valid
/// mode would prevent nothing — the payload carries no protocol meaning, a
/// descriptor is bounded to 32 wire bytes, a random fill is bounded by
/// [`max_packet_length`](Self::max_packet_length) like every other reply, and
/// `none` is zero-filled here rather than left as residue. An operator
/// restriction can be added if a real use case turns up.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServerConfig {
hmac_key: Option<Vec<u8>>,
+14 -9
View File
@@ -10,7 +10,7 @@ use crate::{
clock::{saturating_ns, ClockSample, ClockSource, SystemClock},
config::ServerConfig,
error::ServerError,
fill::negotiate_server_fill,
fill::{echo_payload_len, negotiate_server_fill},
negotiate::negotiate_params,
session::{RateDecision, Session},
token::{OsTokenSource, TokenSource, TOKEN_ATTEMPTS},
@@ -49,8 +49,8 @@ use crate::{
/// normal echo processing, per-session rate limiting, idle expiry,
/// maximum-duration close and client-initiated close. It decides the traffic
/// class each reply is to be sent with; actually applying it to a socket is the
/// runtime's job. The full server fill policy is a separate slice, and echo
/// payloads are zero-filled. Incoming authenticated requests enforce logical
/// runtime's job. It also settles each session's server fill and generates the
/// payload bytes of every echo reply. Incoming authenticated requests enforce logical
/// expiry exactly; the runtime also calls a private maintenance hook to reclaim
/// idle sessions when no traffic arrives.
#[derive(Debug)]
@@ -356,6 +356,10 @@ impl ServerCore {
// it.
let sent_at = self.clock.sample();
let params = session.params();
// Fill bytes are prepared before the reply is built, so they go through
// the encoder's normal path and the packet's MAC covers them like every
// other byte. Nothing mutates a payload after encoding.
let payload_len = echo_payload_len(self.config.hmac_key().is_some(), params);
// Read before the session can be released below: a maximum-duration
// close is an ordinary echo reply and carries the session's marking like
// any other.
@@ -374,12 +378,13 @@ impl ServerCore {
recv_count: params.received_stats.has_count().then_some(next.count),
recv_window: params.received_stats.has_window().then_some(next.window),
timestamps: timestamp_fields(params, request.received_at, sent_at),
// No payload bytes of our own: the encoder zero-fills the whole
// region between the field block and the negotiated length. That is
// the initial safe fill policy — payload bytes carry no protocol
// meaning, and zeroes cannot disclose residue from another request
// or another client. Request payload bytes never reach a reply.
payload: Vec::new(),
// The session's negotiated fill, generated fresh for this reply and
// sized from the layout rather than from `params.length`. A `none`
// session contributes no bytes and the encoder's zero-fill stands,
// which is what keeps a no-fill reply from disclosing residue.
// Nothing from the request reaches here: its payload is opaque, and
// ServerFill describes the server's own bytes.
payload: session.fill().payload(payload_len),
};
let packet = encode_echo_reply(&reply, params, self.config.hmac_key())
.map_err(|source| ServerError::ReplyEncoding { source })?;
+124 -1
View File
@@ -6,7 +6,7 @@
//! [`FillMode`], which is deliberately crate-private: it is neither wire format
//! nor public configuration.
use irtt_proto::{Params, ServerFill};
use irtt_proto::{echo_packet_len, PacketLayout, Params, ServerFill};
/// The bytes of this server's default fill, repeated: `69 72 74 74`, or `irtt`.
///
@@ -70,6 +70,34 @@ impl FillMode {
.map(Self::Pattern),
}
}
/// The `len` payload bytes an echo reply carries under this mode.
///
/// **A pattern always starts at its first byte.** The phase resets for every
/// reply, which is `irtt-rs` policy: the clean evidence records the
/// reference implementation advancing one phase continuously across replies,
/// sessions and listeners, and states equally clearly that payload phase has
/// no protocol significance and that a conforming client must not depend on
/// it. Resetting is therefore interoperability-equivalent, and it buys
/// determinism — no global mutable fill state, no coupling between sessions
/// or listeners, and a reply whose bytes a test can state exactly.
///
/// Infallible by construction. A pattern and an empty region cannot fail,
/// and a random draw that does falls back to zeroes rather than failing the
/// reply.
pub(crate) fn payload(&self, len: usize) -> Vec<u8> {
match self {
// No allocation and no copy: the encoder already zero-fills the
// whole region between the field block and the negotiated length,
// so an empty payload *is* the zero payload. Zero is the policy —
// the clean evidence records the reference server leaving this
// 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::Pattern(pattern) => pattern.iter().copied().cycle().take(len).collect(),
}
}
}
/// The pattern a `pattern:` descriptor's body names, or `None` if the body is
@@ -102,6 +130,30 @@ fn hex_digit(digit: u8) -> Option<u8> {
}
}
/// `len` random bytes drawn with `draw`, or `len` 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.
///
/// 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> {
let mut payload = vec![0; len];
if len > 0 && draw(&mut payload).is_err() {
payload.fill(0);
}
payload
}
/// Settles what fill a session will actually use, restricting `params` only
/// where this server could not honor what was asked for.
///
@@ -151,6 +203,25 @@ pub(crate) fn negotiate_server_fill(params: &mut Params) -> FillMode {
}
}
/// The payload region of one echo reply under `params`, in bytes.
///
/// Derived from `irtt-proto`'s own layout and sizing rather than from
/// `params.length`, which is neither the packet length nor the payload length:
/// the mandatory field block, the negotiated statistics and timestamps, and
/// authentication's 16 bytes all consume packet space, and a length below the
/// field block asks for no payload at all.
///
/// An unrepresentable length yields zero rather than an error. An acknowledged
/// session cannot reach it — the open path already required [`echo_packet_len`]
/// to succeed — and if one somehow did, encoding the reply would fail on that
/// same call, which is where the failure belongs.
///
/// [`echo_packet_len`]: irtt_proto::echo_packet_len
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::*;
@@ -205,4 +276,56 @@ mod tests {
Some(FillMode::default_fill())
);
}
#[test]
fn a_pattern_repeats_from_its_first_byte_and_stops_at_the_region() {
let mode = FillMode::Pattern(vec![0xaa, 0xbb]);
assert_eq!(mode.payload(0), Vec::<u8>::new());
assert_eq!(mode.payload(1), vec![0xaa]);
assert_eq!(
mode.payload(7),
vec![0xaa, 0xbb, 0xaa, 0xbb, 0xaa, 0xbb, 0xaa]
);
}
#[test]
fn no_fill_leaves_the_region_to_the_encoder() {
// Not a length-sized run of zeroes: the encoder has already zeroed the
// region, and copying zeroes over zeroes would only cost an allocation.
assert!(FillMode::None.payload(16).is_empty());
}
#[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]);
}
#[test]
fn a_successful_random_draw_is_propagated_whole() {
// Deterministic on purpose. Nothing here asserts that real random bytes
// are nonzero, distinct or well distributed; those are properties of
// the operating system's source, and testing them would be a coin flip
// dressed up as an assertion.
let drawn = [1, 2, 3, 4, 5];
let source = |buffer: &mut [u8]| -> Result<(), ()> {
assert_eq!(buffer.len(), drawn.len(), "the whole region is offered");
buffer.copy_from_slice(&drawn);
Ok(())
};
assert_eq!(random_payload(drawn.len(), source), drawn);
}
#[test]
fn an_empty_region_never_reaches_the_random_source() {
let source = |_: &mut [u8]| -> Result<(), ()> { panic!("no bytes were needed") };
assert!(random_payload(0, source).is_empty());
}
}
+16 -3
View File
@@ -59,9 +59,22 @@
//! Open handling and negotiation, session creation, normal echo processing,
//! per-session rate limiting, idle expiry, the maximum-duration
//! server-initiated close, the negotiated per-session reply traffic class,
//! wildcard reply-source selection and Tokio UDP orchestration. The full server
//! fill policy and running several listeners in one process remain separate
//! slices. Echo payloads are zero-filled.
//! server fill, wildcard reply-source selection and Tokio UDP orchestration.
//! Running several listeners in one process remains a separate slice, as do
//! optional timestamp and DSCP restriction controls.
//!
//! # Server fill
//!
//! An echo reply's payload region is filled from the session's negotiated
//! ServerFill descriptor: `none`, `rand`, or `pattern:` followed by a repeating
//! hexadecimal pattern. Every valid descriptor is accepted — there is no
//! allow-list to configure — and one this server cannot parse is answered with
//! its default, `pattern:69727474`, which is also what a client expressing no
//! preference is served without any change to the negotiated parameters.
//!
//! A `none` payload is zeroes. Payload bytes carry no protocol meaning, and a
//! server must never emit residue from another request or client. Request
//! payload bytes never reach a reply under any mode.
#![forbid(unsafe_code)]
mod clock;
+14 -3
View File
@@ -725,7 +725,11 @@ fn a_single_clock_midpoint_emits_exactly_one_field() {
}
#[test]
fn a_reply_payload_is_zero_filled_and_never_reflects_the_request() {
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(&params);
@@ -744,7 +748,14 @@ fn a_reply_payload_is_zero_filled_and_never_reflects_the_request() {
assert_eq!(packet.bytes().len(), 64);
assert_eq!(reply.payload.len(), 48);
assert!(
reply.payload.iter().all(|byte| *byte == 0),
"the payload region must be zero-filled, never request or buffer residue"
!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"
);
}
+241 -10
View File
@@ -1,21 +1,31 @@
//! Server fill negotiation: what an open reply reports, and what the session
//! will actually fill with.
//! Server fill: what an open reply reports, what the session will fill with,
//! and the bytes an echo reply actually carries.
//!
//! The two are deliberately different questions. A client that expressed no
//! preference keeps its absent or empty descriptor in the negotiated parameters
//! while the session uses this server's default fill, and only a descriptor the
//! server could not honor is rewritten. Asserting the reply alone would not
//! separate those, so these tests read the session's effective mode too.
//! The first two are deliberately different questions. A client that expressed
//! no preference keeps its absent or empty descriptor in the negotiated
//! parameters while the session uses this server's default fill, and only a
//! descriptor the server could not honor is rewritten. Asserting the reply alone
//! would not separate those, so the negotiation tests read the session's
//! effective mode too, and the payload tests then pin the bytes on the wire.
//!
//! 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::{Params, ServerFill};
use irtt_proto::{echo_header_len, Clock, Params, ReceivedStats, ServerFill, StampAt};
use super::support::{
client_params, core_with_tokens, expect_no_test_reply, expect_normal_open_reply,
no_test_request, open_request, peer, unthrottled, ScriptedTokens,
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,
};
use crate::{core::ServerCore, fill::FillMode};
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 {
@@ -152,3 +162,224 @@ fn a_no_test_open_negotiates_fill_exactly_as_a_normal_open_does() {
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, &params) + 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() {
// The wire consequence of the negotiation test above: absent descriptor,
// 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));
}
+18 -3
View File
@@ -15,7 +15,7 @@
use std::time::Duration;
use irtt_proto::{Clock, Params, ReceivedStats, StampAt, FLAG_CLOSE};
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,
@@ -416,9 +416,20 @@ fn the_maximum_duration_deadline_starts_at_the_first_served_echo() {
#[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, &params_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)),
&params_at(SECOND),
&requested,
ScriptedTokens::new([TOKEN_A]),
None,
);
@@ -437,6 +448,7 @@ fn the_first_echo_past_the_maximum_duration_carries_close_and_ends_the_session()
.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)
@@ -449,7 +461,10 @@ fn the_first_echo_past_the_maximum_duration_carries_close_and_ends_the_session()
(Some(3), Some(0x7)),
"the triggering request is included in the statistics it reports"
);
assert!(reply.payload.is_empty());
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(),