feat(server): enforce rate and session lifetime
This commit is contained in:
10 files changed
+1506
-70
No files matched your search
@@ -20,13 +20,24 @@ use irtt_proto::{
|
||||
|
||||
use super::support::{
|
||||
core_for, core_with_sources, core_with_tokens, echo_params, echo_request, expect_echo_reply,
|
||||
open_negotiated, other_peer, peer, sample, ScriptedClock, ScriptedTokens, KEY, OTHER_KEY,
|
||||
open_negotiated, other_peer, peer, sample, unthrottled, ScriptedClock, ScriptedTokens, KEY,
|
||||
OTHER_KEY,
|
||||
};
|
||||
use crate::ServerConfig;
|
||||
use crate::{clock::ClockSample, ServerConfig};
|
||||
|
||||
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 {
|
||||
@@ -48,10 +59,13 @@ fn counting_params(length: i64) -> Params {
|
||||
)
|
||||
}
|
||||
|
||||
/// Opens one session on a default core and returns the core, the token and the
|
||||
/// negotiated params.
|
||||
/// 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(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), params, None);
|
||||
(core, token, negotiated)
|
||||
}
|
||||
@@ -558,7 +572,7 @@ fn only_the_negotiated_timestamps_are_emitted() {
|
||||
},
|
||||
),
|
||||
] {
|
||||
let clock_source = ScriptedClock::new([received, sent]);
|
||||
let clock_source = ScriptedClock::new([open_sample(), received, sent]);
|
||||
let mut core = core_with_sources(
|
||||
ServerConfig::default(),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
@@ -569,7 +583,7 @@ fn only_the_negotiated_timestamps_are_emitted() {
|
||||
assert_eq!(
|
||||
clock_source.remaining(),
|
||||
2,
|
||||
"an open must not consume a clock sample"
|
||||
"an open consumes exactly its own lifecycle sample"
|
||||
);
|
||||
|
||||
let packet = core
|
||||
@@ -603,7 +617,7 @@ fn a_backward_wall_step_never_reports_receive_after_send() {
|
||||
let mut core = core_with_sources(
|
||||
ServerConfig::default(),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
ScriptedClock::new([sample(1_000, 10_000), sample(990, 10_300)]),
|
||||
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);
|
||||
@@ -633,7 +647,7 @@ fn a_backward_wall_step_never_reports_receive_after_send() {
|
||||
let mut core = core_with_sources(
|
||||
ServerConfig::default(),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
ScriptedClock::new([sample(1_000, 10_000), sample(990, 10_300)]),
|
||||
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);
|
||||
@@ -678,7 +692,7 @@ fn a_single_clock_midpoint_emits_exactly_one_field() {
|
||||
let mut core = core_with_sources(
|
||||
ServerConfig::default(),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
ScriptedClock::new([sample(1_000, 10_000), sample(1_200, 10_300)]),
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,580 @@
|
||||
//! 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::{Clock, Params, ReceivedStats, 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::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(packet: Option<Vec<u8>>, params: &Params) -> Option<u32> {
|
||||
let packet = packet?;
|
||||
expect_echo_reply(&packet, 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 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() {
|
||||
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());
|
||||
// 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");
|
||||
|
||||
// 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!(reply.payload.is_empty());
|
||||
assert_eq!(
|
||||
packet.len(),
|
||||
irtt_proto::echo_packet_len(false, &negotiated).unwrap(),
|
||||
"the negotiated layout, unchanged by the flag"
|
||||
);
|
||||
|
||||
// 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).expect("the close-flagged reply is authenticated");
|
||||
assert_eq!(core.session_count(), 0);
|
||||
}
|
||||
@@ -15,10 +15,12 @@ mod admission;
|
||||
mod close;
|
||||
mod echo;
|
||||
mod hmac;
|
||||
mod lifecycle;
|
||||
mod negotiation;
|
||||
mod no_test;
|
||||
mod open;
|
||||
mod params;
|
||||
mod rate;
|
||||
mod sessions;
|
||||
mod support;
|
||||
mod tokens;
|
||||
@@ -0,0 +1,257 @@
|
||||
//! 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::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(packet: Option<Vec<u8>>, params: &Params) -> Option<u32> {
|
||||
let packet = packet?;
|
||||
expect_echo_reply(&packet, 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)
|
||||
);
|
||||
}
|
||||
@@ -94,8 +94,13 @@ impl TokenSource for ScriptedTokens {
|
||||
///
|
||||
/// 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 when it classifies a datagram as an echo, and a second when
|
||||
/// an admitted echo is about to be answered.
|
||||
/// 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>>>,
|
||||
@@ -131,6 +136,47 @@ 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.
|
||||
@@ -138,11 +184,12 @@ pub(crate) fn core_with_tokens(config: ServerConfig, tokens: ScriptedTokens) ->
|
||||
ServerCore::with_token_source(config, Box::new(tokens))
|
||||
}
|
||||
|
||||
/// A core with both nondeterministic sources scripted, for timestamp tests.
|
||||
/// A core with both nondeterministic sources chosen, for timestamp, rate and
|
||||
/// lifetime tests.
|
||||
pub(crate) fn core_with_sources(
|
||||
config: ServerConfig,
|
||||
tokens: ScriptedTokens,
|
||||
clock: ScriptedClock,
|
||||
clock: impl ClockSource + 'static,
|
||||
) -> ServerCore {
|
||||
ServerCore::with_sources(config, Box::new(tokens), Box::new(clock))
|
||||
}
|
||||
@@ -159,6 +206,40 @@ pub(crate) fn core_for(hmac_key: Option<&[u8]>, tokens: ScriptedTokens) -> Serve
|
||||
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<Vec<u8>> {
|
||||
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,
|
||||
@@ -246,6 +327,24 @@ pub(crate) fn expect_echo_reply(
|
||||
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: &[u8],
|
||||
params: &Params,
|
||||
hmac_key: Option<&[u8]>,
|
||||
) -> EchoReply {
|
||||
let reply =
|
||||
decode_echo_reply(packet, 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(
|
||||
|
||||
Reference in new issue
Block a user