Merge pull request #80 from Lochnair/feat/server-lifecycle-policy
feat(server): enforce rate and session lifetime
This commit was merged in pull request #80.
This commit is contained in:
17 files changed
+1959
-103
No files matched your search
@@ -57,7 +57,7 @@ Tokio must remain optional for `irtt-client`; default builds must remain runtime
|
||||
|
||||
### Server architecture (`irtt-server`)
|
||||
|
||||
`irtt-server` is a first-class reusable server crate. It currently contains the deterministic OPEN/ECHO/session core — packet admission, authentication policy, open negotiation, the bounded session table, open reply construction, echo processing with its per-session receive state and timestamps, and client-initiated close. Rate limiting, session lifetime, server-initiated close, DSCP application and the server fill policy remain unimplemented, as does the Tokio runtime; see `crates/irtt-server/AGENTS.md`. The rest of this section describes the boundary the crate is built to, including the parts not yet written.
|
||||
`irtt-server` is a first-class reusable server crate. It currently contains the deterministic OPEN/ECHO/session core — packet admission, authentication policy, open negotiation, the bounded session table, open reply construction, echo processing with its per-session receive state and timestamps, per-session rate limiting, session lifetime with idle expiry, the maximum-duration server-initiated close, and client-initiated close. DSCP application and the full server fill policy remain unimplemented, as does the Tokio runtime, so session expiry is evaluated when a datagram arrives rather than on a timer; see `crates/irtt-server/AGENTS.md`. The rest of this section describes the boundary the crate is built to, including the parts not yet written.
|
||||
|
||||
- It shares wire semantics with the client through `irtt-proto` and follows the same clean-room boundary.
|
||||
- It must **not** depend on `irtt-client` merely to reuse protocol behavior.
|
||||
@@ -66,7 +66,7 @@ Tokio must remain optional for `irtt-client`; default builds must remain runtime
|
||||
- Protocol/session logic should be separated from socket/runtime orchestration where that materially improves deterministic testing, ownership clarity, state-machine reasoning, or maintainability. A `ServerCore`-like internal boundary is therefore desirable, but its exact name/API is not prescribed, it need not be public, it is not a promise of runtime independence, and simple code should not be tortured merely to make the core artificially pure.
|
||||
- Runtime/server orchestration belongs around that core and may freely use Tokio for UDP sockets, `recv_from`/`send_to`, IPv4/IPv6 listeners, timers, session expiry, max-duration deadlines, shutdown, bounded control/config channels where useful, and `select!`-style orchestration.
|
||||
- The CLI server applet, when added, should remain thin orchestration/configuration over reusable `irtt-server`, not contain a second server state machine.
|
||||
- Resource policy: the server should use bounded session/resource policy. Upstream's observed lack of useful default session/per-peer bounds (see `IRTT_SERVER_PROTOCOL_SPEC.md`) is not an interoperability requirement and must not be reproduced merely for compatibility.
|
||||
- Resource policy: the server should use bounded session/resource policy. Upstream's observed lack of useful default session/per-peer bounds, and its never expiring a session that has not carried an echo request (see `IRTT_SERVER_PROTOCOL_SPEC.md`), are not interoperability requirements and must not be reproduced merely for compatibility.
|
||||
- Testing: prefer `irtt-server` for normal compliant fake-peer behavior where practical, as far as its implemented behavior reaches. Keep narrow adversarial/raw peers for intentionally malformed, ambiguous, or non-compliant wire behavior.
|
||||
|
||||
## Engineering rules
|
||||
|
||||
@@ -144,9 +144,78 @@ effective parameters must be safe for the server to run.
|
||||
- Reply payload bytes are zero, which is the initial safe fill policy: payload
|
||||
carries no protocol meaning, and a server must never emit residue from another
|
||||
request or client. Request payload bytes never reach a reply.
|
||||
- Rate limiting, idle expiry, maximum duration, server-initiated close, DSCP
|
||||
socket application and the full `ServerFill` policy are later slices. Do not
|
||||
add placeholder session fields for them.
|
||||
- DSCP socket application and the full `ServerFill` policy are later slices. Do
|
||||
not add placeholder session fields for them.
|
||||
|
||||
## Rate, lifetime and server-initiated close
|
||||
|
||||
Defaults: minimum send interval 10 ms, burst allowance 5, idle timeout 60 s, no
|
||||
maximum test duration. All four are per-server configuration, and the burst
|
||||
allowance is per session.
|
||||
|
||||
Negotiation applies the interval floor first and the (idle timeout ÷ 4) cap
|
||||
second, so a configured minimum above a quarter of the timeout ends with the cap
|
||||
winning and the returned interval below that minimum. A configured maximum test
|
||||
duration reduces a longer requested Duration and also replaces a *continuous*
|
||||
request (an absent Duration), because restricting continuous mode to a finite
|
||||
test is what configuring a maximum means. A zero configured maximum is stored as
|
||||
no maximum: Duration zero on the wire means continuous, so a finite maximum of
|
||||
zero could only be expressed by making a client's finite test endless.
|
||||
|
||||
Several choices below deliberately differ from the reference server's observed
|
||||
behavior. Each is **`irtt-rs` project policy**, taken where the clean evidence
|
||||
records upstream behavior as policy rather than an interoperability requirement.
|
||||
Do not rewrite the protocol evidence to match them.
|
||||
|
||||
- **The refill interval is the shorter of the configured minimum and the
|
||||
negotiated interval.** The specification records upstream replenishing on the
|
||||
configured minimum regardless, so its own idle cap can hand a client a 2 s
|
||||
interval while the limiter enforces 5 s and rate-limits a fully conforming
|
||||
client. A server must not enforce a cadence it did not advertise. Ordinary
|
||||
configurations are unaffected: a 10 ms minimum against a 1 s negotiated
|
||||
interval still refills every 10 ms.
|
||||
- **Zero means two different things and neither is "unlimited".** A zero minimum
|
||||
send interval is no time-based throttling; a zero burst allowance is no
|
||||
allowance at all, so every echo is rate-limited.
|
||||
- **A rate-limited echo refreshes the idle deadline and advances no statistic.**
|
||||
This one *is* observed behavior — the only tested drop class that refreshes —
|
||||
and is kept deliberately.
|
||||
- **The idle deadline runs from the open**, not from the first echo request. A
|
||||
session that never carries one still ages out; upstream's never-expiring
|
||||
unused session is a resource leak the specification recommends against.
|
||||
- **Expiry is immediate and silent at the configured deadline.** No five-second
|
||||
additive grace, no final lazy-release reply to the first echo that finds an
|
||||
expired session, and no request-class-dependent "who consumed it" behavior.
|
||||
The only interoperability constraint is negative — expiry must never be
|
||||
signalled — and silence satisfies it. A zero idle timeout expires a session at
|
||||
the next evaluation; it is not "never expire".
|
||||
- **Expiry is packet-driven until the runtime exists.** Logical expiry is
|
||||
timestamp-based, but reclamation happens in a global sweep run after
|
||||
authentication and before dispatch, so an expired session may stay resident
|
||||
until the next authenticated, structurally valid request. Nothing observable
|
||||
turns on it: the table is already bounded, and the sweep runs before capacity
|
||||
is judged, so a stale session cannot deny an open. Because the sweep is global
|
||||
it also runs for a no-test open, where upstream was observed not to reclaim.
|
||||
Do not add a public timer or tick abstraction for this; the Tokio slice may add
|
||||
scheduled sweeps.
|
||||
- **The maximum-duration deadline is `first served echo + maximum + 2 s`.** The
|
||||
origin is measured behavior: neither the open, nor a rejected first echo, nor a
|
||||
rate-limited one starts it. The two-second grace matches the measured upstream
|
||||
margin and is a fixed internal constant, not a configuration knob.
|
||||
- **Rate allowance is judged before the maximum-duration close.** A
|
||||
deadline-crossing echo with no allowance is dropped silently and the close is
|
||||
carried by the next echo that is served. Other rejected classes likewise do not
|
||||
trigger or defer it — the specification records that case as untested, so this
|
||||
is the deterministic reading rather than an observed one.
|
||||
- **A server close is an ordinary echo reply with `FLAG_CLOSE` added**, carrying
|
||||
the triggering request's sequence number, statistics, timestamps and
|
||||
authentication. There is no standalone close packet in protocol version 1. The
|
||||
session is released once that reply has encoded, so every later request is an
|
||||
unknown token.
|
||||
|
||||
Every state transition — receive, rate and lifetime — is computed purely and
|
||||
committed only after the reply has encoded, so an encoding failure leaves the
|
||||
session exactly as it was and releases nothing.
|
||||
|
||||
## Resource policy
|
||||
|
||||
@@ -174,7 +243,18 @@ half-created session.
|
||||
- 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 tests free of sleeps, tolerances and real wall-clock assertions.
|
||||
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.
|
||||
- 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
|
||||
do not add accessors for allowance, activity instants or the maximum-duration
|
||||
origin.
|
||||
- As the server gains behavior, prefer it over fake peers for normal compliant
|
||||
behavior in other crates' tests. Keep small raw/adversarial peers for
|
||||
intentionally malformed or non-compliant wire behavior.
|
||||
@@ -125,11 +125,18 @@ fn wall_ns() -> i64 {
|
||||
|
||||
/// A duration as nanoseconds, saturating rather than panicking.
|
||||
///
|
||||
/// This is the crate's one [`Duration`] → wire-nanoseconds conversion, used for
|
||||
/// clock readings and for the configured intervals and deadlines that
|
||||
/// negotiation and lifetime policy compare against them. Everything on both
|
||||
/// sides is signed nanoseconds, so there is no second rule anywhere.
|
||||
///
|
||||
/// [`Duration`] counts nanoseconds in `u128` and reaches far beyond `i64`,
|
||||
/// which the wire field is. A host clock set past the year 2262 is a local
|
||||
/// misconfiguration; it must not become a panic that a remote peer can reach by
|
||||
/// sending an echo request.
|
||||
fn saturating_ns(duration: Duration) -> i64 {
|
||||
/// which the wire field is. A host clock set past the year 2262, or an operator
|
||||
/// configuring a millennium-long idle timeout, is a local matter; neither may
|
||||
/// become a panic that a remote peer can reach by sending a packet. Saturating
|
||||
/// loses nothing that could have been honored, since no wire interval or
|
||||
/// duration can express more than `i64::MAX` nanoseconds either.
|
||||
pub(crate) fn saturating_ns(duration: Duration) -> i64 {
|
||||
i64::try_from(duration.as_nanos()).unwrap_or(i64::MAX)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::time::Duration;
|
||||
|
||||
/// Default bound on the number of simultaneously live sessions.
|
||||
///
|
||||
/// This is `irtt-rs` resource policy, not an interoperability requirement. The
|
||||
@@ -26,18 +28,46 @@ pub const DEFAULT_MAX_SESSIONS: usize = 1024;
|
||||
/// allocation bounded, not to predict fragmentation.
|
||||
pub const DEFAULT_MAX_PACKET_LENGTH: usize = 65_507;
|
||||
|
||||
/// Default floor on the send interval a session may negotiate, and the cadence
|
||||
/// its reply allowance replenishes at.
|
||||
///
|
||||
/// This matches the reference server's default policy. It is policy on both
|
||||
/// sides: the server specification lists the minimum send interval among the
|
||||
/// reference server's freely changeable defaults, not among the values the wire
|
||||
/// format fixes.
|
||||
pub const DEFAULT_MIN_SEND_INTERVAL: Duration = Duration::from_millis(10);
|
||||
|
||||
/// Default number of echo requests a session may have answered before its
|
||||
/// allowance has to replenish.
|
||||
///
|
||||
/// This matches the reference server's default policy, and like it, the
|
||||
/// allowance is per session: one session's burst is not another's.
|
||||
pub const DEFAULT_BURST_ALLOWANCE: u32 = 5;
|
||||
|
||||
/// Default idle lifetime of a session.
|
||||
///
|
||||
/// A session that goes this long without an echo request is released. This
|
||||
/// matches the reference server's default of one minute, but the deadline it
|
||||
/// measures is deliberately not upstream's: `irtt-rs` expires immediately at the
|
||||
/// deadline, applies it to sessions that have never carried an echo request, and
|
||||
/// emits no final reply. See [`ServerConfig::with_idle_timeout`].
|
||||
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. Only the
|
||||
/// settings this OPEN/session slice actually uses exist yet; the remaining
|
||||
/// parameter restriction knobs, lifetime policy and rate limits arrive with the
|
||||
/// slices that implement them.
|
||||
/// 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.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ServerConfig {
|
||||
hmac_key: Option<Vec<u8>>,
|
||||
max_sessions: usize,
|
||||
max_packet_length: usize,
|
||||
min_send_interval: Duration,
|
||||
burst_allowance: u32,
|
||||
idle_timeout: Duration,
|
||||
max_test_duration: Option<Duration>,
|
||||
}
|
||||
|
||||
impl ServerConfig {
|
||||
@@ -90,6 +120,97 @@ impl ServerConfig {
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the floor on the send interval a session may negotiate, which is
|
||||
/// also the cadence its reply allowance replenishes at.
|
||||
///
|
||||
/// A requested interval below this is raised to it during negotiation, and
|
||||
/// an absent Interval — which means the wire default of zero, not an
|
||||
/// explicit zero, which is refused — is answered with it. The negotiated
|
||||
/// value may still end up *below* this floor, because the idle-timeout cap
|
||||
/// of [`with_idle_timeout`](Self::with_idle_timeout) is applied afterwards;
|
||||
/// where that happens, the session's allowance replenishes at the shorter
|
||||
/// negotiated interval instead, so this server never enforces a cadence
|
||||
/// slower than the one it handed the client.
|
||||
///
|
||||
/// [`Duration::ZERO`] disables both: no interval is raised, and an
|
||||
/// otherwise admissible echo never waits for allowance. It does **not**
|
||||
/// disable the burst allowance, which refuses everything when it is zero.
|
||||
#[must_use]
|
||||
pub fn with_min_send_interval(mut self, min_send_interval: Duration) -> Self {
|
||||
self.min_send_interval = min_send_interval;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets how many echo requests a session may have answered before its
|
||||
/// allowance has to replenish.
|
||||
///
|
||||
/// The allowance is per session, starts full, is spent one unit per served
|
||||
/// echo and refills at one unit per
|
||||
/// [`min_send_interval`](Self::min_send_interval), never above this value.
|
||||
/// An echo arriving with no allowance is dropped without a reply and
|
||||
/// without advancing any reception statistic.
|
||||
///
|
||||
/// Zero is accepted and means no allowance at all: every echo is
|
||||
/// rate-limited, whatever the interval is set to. It is deliberately not a
|
||||
/// synonym for unlimited.
|
||||
#[must_use]
|
||||
pub fn with_burst_allowance(mut self, burst_allowance: u32) -> Self {
|
||||
self.burst_allowance = burst_allowance;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets how long a session may go without a served or rate-limited echo
|
||||
/// request before it is released.
|
||||
///
|
||||
/// The deadline runs from the open, not from the first echo, so a session
|
||||
/// that never carries one still ages out. Release is silent — the protocol
|
||||
/// defines no expiry notification — and immediate at the deadline.
|
||||
///
|
||||
/// This additionally caps the negotiated send interval at a quarter of the
|
||||
/// timeout, so a client sending at the interval it was given cannot idle
|
||||
/// itself out.
|
||||
///
|
||||
/// [`Duration::ZERO`] is accepted and is not a synonym for "never expire":
|
||||
/// it releases a session the next time expiry is evaluated. There is
|
||||
/// deliberately no unbounded setting.
|
||||
#[must_use]
|
||||
pub fn with_idle_timeout(mut self, idle_timeout: Duration) -> Self {
|
||||
self.idle_timeout = idle_timeout;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the maximum test duration a session may negotiate.
|
||||
///
|
||||
/// A requested Duration above this is reduced to it, and a *continuous*
|
||||
/// request — an absent Duration, meaning the wire default of zero — is
|
||||
/// answered with it too, since restricting continuous mode to a finite test
|
||||
/// is the whole point of configuring a maximum. An explicit zero Duration
|
||||
/// is refused before negotiation and never reaches this.
|
||||
///
|
||||
/// [`Duration::ZERO`] means *no maximum*, exactly as
|
||||
/// [`without_max_test_duration`](Self::without_max_test_duration) does. A
|
||||
/// finite maximum of zero could only be honored by negotiating a Duration of
|
||||
/// zero, which on the wire means continuous — the opposite of a limit — so
|
||||
/// rewriting a client's finite request into a continuous one is refused
|
||||
/// rather than encoded.
|
||||
#[must_use]
|
||||
pub fn with_max_test_duration(mut self, max_test_duration: Duration) -> Self {
|
||||
self.max_test_duration = (!max_test_duration.is_zero()).then_some(max_test_duration);
|
||||
self
|
||||
}
|
||||
|
||||
/// Removes any configured maximum test duration, which is the default.
|
||||
///
|
||||
/// A session then negotiates the duration it asked for, including
|
||||
/// continuous, and no server-initiated close is ever triggered by duration.
|
||||
/// The session table bound and the idle timeout still apply, so this is not
|
||||
/// an unbounded setting.
|
||||
#[must_use]
|
||||
pub fn without_max_test_duration(mut self) -> Self {
|
||||
self.max_test_duration = None;
|
||||
self
|
||||
}
|
||||
|
||||
/// The configured HMAC key, if this server authenticates.
|
||||
#[must_use]
|
||||
pub fn hmac_key(&self) -> Option<&[u8]> {
|
||||
@@ -107,16 +228,56 @@ impl ServerConfig {
|
||||
pub fn max_packet_length(&self) -> usize {
|
||||
self.max_packet_length
|
||||
}
|
||||
|
||||
/// The floor on a negotiated send interval, and the allowance refill
|
||||
/// cadence.
|
||||
#[must_use]
|
||||
pub fn min_send_interval(&self) -> Duration {
|
||||
self.min_send_interval
|
||||
}
|
||||
|
||||
/// How many echo requests a session may have answered before its allowance
|
||||
/// has to replenish.
|
||||
#[must_use]
|
||||
pub fn burst_allowance(&self) -> u32 {
|
||||
self.burst_allowance
|
||||
}
|
||||
|
||||
/// How long a session may go without an echo request before it is released.
|
||||
#[must_use]
|
||||
pub fn idle_timeout(&self) -> Duration {
|
||||
self.idle_timeout
|
||||
}
|
||||
|
||||
/// The maximum test duration a session may negotiate, if one is configured.
|
||||
///
|
||||
/// Never `Some(Duration::ZERO)`: a zero maximum is stored as `None`, since
|
||||
/// a negotiated Duration of zero means continuous rather than instant.
|
||||
#[must_use]
|
||||
pub fn max_test_duration(&self) -> Option<Duration> {
|
||||
self.max_test_duration
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ServerConfig {
|
||||
/// An unauthenticated server bounded to [`DEFAULT_MAX_SESSIONS`] sessions,
|
||||
/// each negotiating at most [`DEFAULT_MAX_PACKET_LENGTH`] bytes per echo.
|
||||
/// each negotiating at most [`DEFAULT_MAX_PACKET_LENGTH`] bytes per echo,
|
||||
/// no faster than [`DEFAULT_MIN_SEND_INTERVAL`] with a burst of
|
||||
/// [`DEFAULT_BURST_ALLOWANCE`], and released after
|
||||
/// [`DEFAULT_IDLE_TIMEOUT`] without an echo request.
|
||||
///
|
||||
/// No maximum test duration is configured, matching the reference server's
|
||||
/// ordinary policy. A session is still bounded, by the table size and the
|
||||
/// idle timeout.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
hmac_key: None,
|
||||
max_sessions: DEFAULT_MAX_SESSIONS,
|
||||
max_packet_length: DEFAULT_MAX_PACKET_LENGTH,
|
||||
min_send_interval: DEFAULT_MIN_SEND_INTERVAL,
|
||||
burst_allowance: DEFAULT_BURST_ALLOWANCE,
|
||||
idle_timeout: DEFAULT_IDLE_TIMEOUT,
|
||||
max_test_duration: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
+130
-22
@@ -7,11 +7,11 @@ use irtt_proto::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
clock::{ClockSample, ClockSource, SystemClock},
|
||||
clock::{saturating_ns, ClockSample, ClockSource, SystemClock},
|
||||
config::ServerConfig,
|
||||
error::ServerError,
|
||||
negotiate::negotiate_params,
|
||||
session::Session,
|
||||
session::{RateDecision, Session},
|
||||
token::{OsTokenSource, TokenSource, TOKEN_ATTEMPTS},
|
||||
};
|
||||
|
||||
@@ -39,11 +39,12 @@ use crate::{
|
||||
///
|
||||
/// # Scope
|
||||
///
|
||||
/// This slice implements open handling, session creation, normal echo
|
||||
/// processing and client-initiated close. Rate limiting, session lifetime and
|
||||
/// expiry, server-initiated close, DSCP application and the server fill policy
|
||||
/// are separate slices, so every otherwise admissible echo is answered and no
|
||||
/// session ever ages out.
|
||||
/// This slice implements open handling, session creation and negotiation,
|
||||
/// normal echo processing, per-session rate limiting, idle expiry,
|
||||
/// maximum-duration close and client-initiated close. DSCP application and the
|
||||
/// full server fill policy are separate slices, as is the Tokio runtime — so
|
||||
/// expiry is evaluated when a datagram arrives rather than on a timer, and
|
||||
/// echo payloads are zero-filled.
|
||||
#[derive(Debug)]
|
||||
pub struct ServerCore {
|
||||
config: ServerConfig,
|
||||
@@ -122,7 +123,14 @@ impl ServerCore {
|
||||
///
|
||||
/// `Ok(None)` is not only a rejection, though. A close request that does
|
||||
/// release its session is answered with nothing as well, because protocol
|
||||
/// version 1 defines no acknowledgement for one.
|
||||
/// version 1 defines no acknowledgement for one — and so is an echo that
|
||||
/// found its session's rate allowance spent, which advances no statistic
|
||||
/// but does keep the session alive.
|
||||
///
|
||||
/// Sessions whose idle deadline has passed are released before the request
|
||||
/// is dispatched, so a request may find the session it names already gone,
|
||||
/// and one request may release an unrelated session. Expiry is never
|
||||
/// signalled; a client sees only silence.
|
||||
///
|
||||
/// Authentication is checked before open parameters are decoded and before
|
||||
/// any session is looked up. That ordering is deliberate: a packet that
|
||||
@@ -165,8 +173,23 @@ impl ServerCore {
|
||||
}
|
||||
}
|
||||
|
||||
// One monotonic instant per authenticated, structurally valid request,
|
||||
// which is the request's whole lifecycle time: what expiry is judged
|
||||
// against, what a new session's deadlines start from, and what the rate
|
||||
// limiter and maximum-duration deadline read. An echo already has one —
|
||||
// the instant its reply will report as the receive time — and taking a
|
||||
// second would let a reply's timestamps disagree with the policy
|
||||
// decisions made about the very same datagram.
|
||||
let now_ns = match kind {
|
||||
ClassifiedKind::Echo(ref echo) => echo.received_at.mono_ns,
|
||||
_ => self.clock.sample().mono_ns,
|
||||
};
|
||||
self.release_expired_sessions(now_ns);
|
||||
|
||||
match kind {
|
||||
ClassifiedKind::Open { no_test, params } => self.handle_open(peer, no_test, params),
|
||||
ClassifiedKind::Open { no_test, params } => {
|
||||
self.handle_open(peer, no_test, params, now_ns)
|
||||
}
|
||||
ClassifiedKind::Close { token } => {
|
||||
self.handle_close(peer, token);
|
||||
Ok(None)
|
||||
@@ -175,6 +198,32 @@ impl ServerCore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops every session whose idle deadline has passed, as of `now_ns`.
|
||||
///
|
||||
/// Expiry is *logical* — a session is expired the instant its deadline
|
||||
/// passes — but reclamation here is *packet-driven*, because this slice has
|
||||
/// no runtime and so no timer. An expired session may therefore stay
|
||||
/// resident until the next authenticated, structurally valid request causes
|
||||
/// this sweep. Nothing observable turns on the difference: total state is
|
||||
/// already bounded by [`max_sessions`](ServerConfig::max_sessions), the
|
||||
/// sweep runs before a new open is admitted so a stale session cannot deny
|
||||
/// capacity to a live one, and while no traffic arrives at all there is
|
||||
/// no peer to observe anything. The Tokio slice may add scheduled sweeps.
|
||||
///
|
||||
/// The sweep is global and runs before the request is dispatched, so a
|
||||
/// request touching one session can release another. That is deliberate:
|
||||
/// expiry is a resource decision about the table, not a side effect of what
|
||||
/// the current datagram happens to name. It also means a *no-test* open —
|
||||
/// which creates nothing of its own — reclaims expired sessions like any
|
||||
/// other open, where the reference server was observed not to. Both are
|
||||
/// upstream policy the clean specification declines to require, and a
|
||||
/// client cannot tell the difference: expiry is never signalled.
|
||||
fn release_expired_sessions(&mut self, now_ns: i64) {
|
||||
let idle_timeout_ns = saturating_ns(self.config.idle_timeout());
|
||||
self.sessions
|
||||
.retain(|_, session| !session.is_idle_expired(now_ns, idle_timeout_ns));
|
||||
}
|
||||
|
||||
/// Pairs a structurally decoded request with the clock sample an echo
|
||||
/// needs, leaving every other kind untouched.
|
||||
fn classify<'a>(
|
||||
@@ -206,9 +255,10 @@ impl ServerCore {
|
||||
/// discard: the datagram must not be longer than
|
||||
/// [`max_packet_length`](ServerConfig::max_packet_length), its token must
|
||||
/// name a live session, and it must have arrived from that session's own
|
||||
/// endpoint. Authentication was already settled by the caller. A request
|
||||
/// failing any of these leaves the receive state — of this session and of
|
||||
/// every other — exactly where it was.
|
||||
/// endpoint. Authentication was already settled by the caller, as was idle
|
||||
/// expiry — a request reaching here found its session still alive. A
|
||||
/// request failing any of these leaves the receive state — of this session
|
||||
/// and of every other — exactly where it was.
|
||||
///
|
||||
/// Only the token and the sequence number mean anything. The request's own
|
||||
/// length is judged against the configured maximum and then forgotten: it
|
||||
@@ -216,9 +266,24 @@ impl ServerCore {
|
||||
/// reply, which is laid out and sized from the session's negotiated
|
||||
/// parameters alone.
|
||||
///
|
||||
/// The next receive state is computed but not committed until the reply has
|
||||
/// **Rate allowance is judged after admission and before the
|
||||
/// maximum-duration deadline.** That order is the observed one and it
|
||||
/// matters: a post-deadline echo with no allowance is simply dropped, and
|
||||
/// the close it would have carried is not lost but handed to the next echo
|
||||
/// that is served. A rate-limited request refreshes the session's idle
|
||||
/// deadline — the one drop class the clean evidence records doing so — and
|
||||
/// changes nothing else.
|
||||
///
|
||||
/// **The maximum-duration close is an ordinary reply with the Close flag
|
||||
/// added**, never a standalone packet: protocol version 1 has no other
|
||||
/// in-band mechanism. It reports the triggering request in its statistics
|
||||
/// exactly as any reply would, and the session is released once it has
|
||||
/// encoded, so every later request is dropped as an unknown token.
|
||||
///
|
||||
/// Every state transition is computed but not committed until the reply has
|
||||
/// been encoded, so a reply this server failed to build cannot leave a
|
||||
/// session claiming to have answered a request it never did.
|
||||
/// session claiming to have answered a request it never did — or, for a
|
||||
/// close, leave the session released with no reply carrying the flag.
|
||||
fn handle_echo(
|
||||
&mut self,
|
||||
peer: SocketAddr,
|
||||
@@ -239,7 +304,29 @@ impl ServerCore {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let now_ns = request.received_at.mono_ns;
|
||||
let next_rate = match session.rate_state().charge(now_ns) {
|
||||
RateDecision::Limited { next } => {
|
||||
// Silence, and no statistic moves — but the session lives on,
|
||||
// and its idle deadline moves with it.
|
||||
let next_lifetime = session.lifetime_state().refreshed(now_ns);
|
||||
session.commit_rate_state(next);
|
||||
session.commit_lifetime_state(next_lifetime);
|
||||
return Ok(None);
|
||||
}
|
||||
RateDecision::Allowed { next } => next,
|
||||
};
|
||||
|
||||
// Only a session that has already served an echo has a deadline at all,
|
||||
// so this can never fire on the request that starts the test.
|
||||
let closing = self.config.max_test_duration().is_some_and(|maximum| {
|
||||
session
|
||||
.lifetime_state()
|
||||
.max_duration_reached(now_ns, saturating_ns(maximum))
|
||||
});
|
||||
|
||||
let next = session.receive_state().accepted(request.sequence);
|
||||
let next_lifetime = session.lifetime_state().served(now_ns);
|
||||
// The departure side, sampled as late as the core can: after admission
|
||||
// and the transition, immediately before the reply is built. There is
|
||||
// no socket here, so this is the core's departure instant rather than a
|
||||
@@ -248,11 +335,12 @@ impl ServerCore {
|
||||
let sent_at = self.clock.sample();
|
||||
let params = session.params();
|
||||
let reply = EchoReply {
|
||||
// Reply only. Open must be clear or upstream clients abort, and
|
||||
// Close belongs to the server-initiated close slice. The
|
||||
// authentication flag and field are the encoder's, decided by the
|
||||
// key it is given.
|
||||
flags: FLAG_REPLY,
|
||||
// Open must be clear or upstream clients abort. Close is set only
|
||||
// for the maximum-duration limit, which is the sole trigger this
|
||||
// server has. The authentication flag and field are the encoder's,
|
||||
// decided by the key it is given — a close-flagged reply is
|
||||
// authenticated by exactly the same path as any other.
|
||||
flags: FLAG_REPLY | if closing { FLAG_CLOSE } else { 0 },
|
||||
// Both are copied through unchanged: no token is allocated for an
|
||||
// echo, and no sequence number is normalized or range-checked.
|
||||
token: request.token,
|
||||
@@ -270,16 +358,34 @@ impl ServerCore {
|
||||
let packet = encode_echo_reply(&reply, params, self.config.hmac_key())
|
||||
.map_err(|source| ServerError::ReplyEncoding { source })?;
|
||||
|
||||
// The last fallible step is behind us, so the transition can be made.
|
||||
// The last fallible step is behind us, so the transitions can be made.
|
||||
if closing {
|
||||
// The reply already carries the state this request produced, so
|
||||
// there is nothing worth committing into a session about to go. A
|
||||
// failure above would have left it live and untouched instead.
|
||||
self.sessions.remove(&request.token);
|
||||
} else {
|
||||
session.commit_rate_state(next_rate);
|
||||
session.commit_receive_state(next);
|
||||
session.commit_lifetime_state(next_lifetime);
|
||||
}
|
||||
Ok(Some(packet))
|
||||
}
|
||||
|
||||
/// Negotiates an open request and, unless it is a no-test one, creates the
|
||||
/// session it asks for.
|
||||
///
|
||||
/// `now_ns` is the request's lifecycle instant, which becomes the new
|
||||
/// session's idle origin: a session starts aging the moment it exists, not
|
||||
/// when it first carries an echo, so one whose client never returns is
|
||||
/// still reclaimed. Expired sessions were already swept, so the capacity
|
||||
/// bound below is judged against sessions that are genuinely live.
|
||||
fn handle_open(
|
||||
&mut self,
|
||||
peer: SocketAddr,
|
||||
no_test: bool,
|
||||
encoded_params: &[u8],
|
||||
now_ns: i64,
|
||||
) -> Result<Option<Vec<u8>>, ServerError> {
|
||||
// The parameter decoder already rejects a truncated tag or value, a
|
||||
// varint overflow, an out-of-range stats/stamp-at/clock enum, and a
|
||||
@@ -325,8 +431,10 @@ impl ServerCore {
|
||||
|
||||
// The reply is encoded before its params move into the session, so
|
||||
// nothing is cloned to keep both.
|
||||
self.sessions
|
||||
.insert(token, Session::new(peer, reply.params));
|
||||
self.sessions.insert(
|
||||
token,
|
||||
Session::new(peer, reply.params, &self.config, now_ns),
|
||||
);
|
||||
Ok(Some(packet))
|
||||
}
|
||||
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
//! # Structure
|
||||
//!
|
||||
//! [`ServerCore`] is the deterministic protocol and session engine: packet
|
||||
//! admission, authentication policy, open negotiation, the session table,
|
||||
//! echo receive state, resource decisions and reply construction. It performs
|
||||
//! no I/O; the clock its echo timestamps come from is a private injected seam,
|
||||
//! not a runtime abstraction, so the engine stays testable. Socket and
|
||||
//! runtime orchestration — the UDP listener, address handling, timers, expiry
|
||||
//! and shutdown — will live around it and is intentionally Tokio-native. There
|
||||
//! is no blocking or alternate-runtime counterpart and no transport
|
||||
//! abstraction.
|
||||
//! 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. Socket and runtime orchestration — the UDP listener,
|
||||
//! address handling, timers, scheduled sweeps and shutdown — will live around
|
||||
//! it and is intentionally Tokio-native. There is no blocking or
|
||||
//! alternate-runtime counterpart and no transport abstraction.
|
||||
//!
|
||||
//! # Rejection is silence
|
||||
//!
|
||||
@@ -27,19 +27,23 @@
|
||||
//!
|
||||
//! Total session state is bounded (see [`DEFAULT_MAX_SESSIONS`]), and so is the
|
||||
//! echo datagram a single session can negotiate (see
|
||||
//! [`DEFAULT_MAX_PACKET_LENGTH`]). A single unauthenticated datagram creates a
|
||||
//! session and opens are never deduplicated, so neither an unbounded table nor a
|
||||
//! remotely chosen buffer size is a compatibility feature. Upstream's observed
|
||||
//! lack of session bounds, and its unlimited default packet length, are
|
||||
//! explicitly not compatibility targets.
|
||||
//! [`DEFAULT_MAX_PACKET_LENGTH`]), the rate one session is answered at (see
|
||||
//! [`DEFAULT_MIN_SEND_INTERVAL`] and [`DEFAULT_BURST_ALLOWANCE`]) and how long
|
||||
//! one lives without traffic (see [`DEFAULT_IDLE_TIMEOUT`]). A single
|
||||
//! unauthenticated datagram creates a session and opens are never deduplicated,
|
||||
//! so neither an unbounded table nor a remotely chosen buffer size is a
|
||||
//! compatibility feature. Upstream's observed lack of session bounds, its
|
||||
//! unlimited default packet length, and its never expiring a session that has
|
||||
//! not carried an echo request are explicitly not compatibility targets: an
|
||||
//! `irtt-rs` session ages from the moment it is opened.
|
||||
//!
|
||||
//! # Current scope
|
||||
//!
|
||||
//! Open handling, session creation, normal echo processing and
|
||||
//! client-initiated close. Rate limiting, session lifetime and expiry,
|
||||
//! server-initiated close, DSCP application, the server fill policy and the
|
||||
//! Tokio runtime are separate slices: every otherwise admissible echo is
|
||||
//! answered, and a session lives until its client closes it.
|
||||
//! Open handling and negotiation, session creation, normal echo processing,
|
||||
//! per-session rate limiting, idle expiry and the maximum-duration
|
||||
//! server-initiated close. DSCP application, the full server fill policy and
|
||||
//! the Tokio runtime are separate slices: echo payloads are zero-filled, and
|
||||
//! expiry is evaluated when a datagram arrives rather than on a timer.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod clock;
|
||||
@@ -53,6 +57,9 @@ mod token;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use config::{ServerConfig, DEFAULT_MAX_PACKET_LENGTH, DEFAULT_MAX_SESSIONS};
|
||||
pub use config::{
|
||||
ServerConfig, DEFAULT_BURST_ALLOWANCE, DEFAULT_IDLE_TIMEOUT, DEFAULT_MAX_PACKET_LENGTH,
|
||||
DEFAULT_MAX_SESSIONS, DEFAULT_MIN_SEND_INTERVAL,
|
||||
};
|
||||
pub use core::ServerCore;
|
||||
pub use error::ServerError;
|
||||
@@ -1,12 +1,10 @@
|
||||
use irtt_proto::{Params, PROTOCOL_VERSION};
|
||||
|
||||
use crate::config::ServerConfig;
|
||||
use crate::{clock::saturating_ns, config::ServerConfig};
|
||||
|
||||
/// Produces the parameters the server returns in an open reply and will enforce
|
||||
/// for the session.
|
||||
///
|
||||
/// Two restrictions apply so far.
|
||||
///
|
||||
/// **Protocol version is rewritten to [`PROTOCOL_VERSION`].** A server must
|
||||
/// return version 1 so that version-1 clients accept the session; any requested
|
||||
/// value, including an absent, zero, negative or future one, is accepted and
|
||||
@@ -21,11 +19,31 @@ use crate::config::ServerConfig;
|
||||
/// floors echo sizing at, so there is nothing to reduce and rewriting them would
|
||||
/// answer with a session the client did not ask for.
|
||||
///
|
||||
/// Those two are the whole of the current policy. A server *may* reduce or
|
||||
/// replace requested parameters, and later policy slices will add the maximum
|
||||
/// test duration clamp,
|
||||
/// minimum interval, idle-timeout interval cap, timestamp allowance, DSCP policy
|
||||
/// and server-fill allow-list.
|
||||
/// **Duration is reduced to the configured maximum test duration**, when one is
|
||||
/// configured. A *continuous* request — Duration absent, which is the wire
|
||||
/// default of zero — is answered with the maximum as well, because restricting
|
||||
/// continuous mode to a finite test is exactly what configuring a maximum asks
|
||||
/// for; the client models that as an ordinary duration reduction. An explicit
|
||||
/// zero or negative Duration never reaches here, being refused during request
|
||||
/// admission, and a configured maximum is never zero, so a finite request can
|
||||
/// never be rewritten into a continuous session.
|
||||
///
|
||||
/// **Interval is raised to the configured minimum and then capped at a quarter
|
||||
/// of the idle timeout.** The floor bounds how fast a session may ask to be
|
||||
/// answered; the cap keeps a well-behaved client from idling itself out by
|
||||
/// sending at the very interval it was given. An absent Interval takes the floor
|
||||
/// like any other value below it, since absence means the wire default of zero
|
||||
/// rather than a refusal — an explicit zero is refused during request admission.
|
||||
///
|
||||
/// The two can disagree: a configured minimum above a quarter of the idle
|
||||
/// timeout ends with the cap winning, and the returned interval below the
|
||||
/// configured minimum. That is deliberate, and `rate` is where the consequence
|
||||
/// is settled — the session's allowance then replenishes at the *negotiated*
|
||||
/// interval, so this server never enforces a cadence slower than the one it just
|
||||
/// handed the client.
|
||||
///
|
||||
/// Later policy slices will add the timestamp allowance, DSCP policy and the
|
||||
/// server-fill allow-list.
|
||||
///
|
||||
/// Negotiation is not by itself an acknowledgement: the core validates what this
|
||||
/// function returns and silently discards an open whose effective parameters it
|
||||
@@ -46,5 +64,32 @@ pub(crate) fn negotiate_params(mut requested: Params, config: &ServerConfig) ->
|
||||
requested.length = max_length;
|
||||
}
|
||||
|
||||
if let Some(maximum) = config.max_test_duration() {
|
||||
// Never zero — `with_max_test_duration` stores a zero maximum as no
|
||||
// maximum — so this cannot turn a finite request into a continuous one.
|
||||
let maximum = saturating_ns(maximum);
|
||||
if requested.duration_ns == 0 || requested.duration_ns > maximum {
|
||||
requested.duration_ns = maximum;
|
||||
}
|
||||
}
|
||||
|
||||
// Floor first, cap second. The order is what lets the cap produce a
|
||||
// negotiated interval below the configured minimum, which is the case the
|
||||
// rate limiter is written to honor rather than contradict.
|
||||
let minimum = saturating_ns(config.min_send_interval());
|
||||
if requested.interval_ns < minimum {
|
||||
requested.interval_ns = minimum;
|
||||
}
|
||||
// Divided as a `Duration`, before the conversion rather than after it. The
|
||||
// two agree for every timeout `i64` nanoseconds can hold, but saturating
|
||||
// first would cap an absurdly long timeout's quarter at `i64::MAX / 4` —
|
||||
// reducing intervals the configured cap never meant to reach — where
|
||||
// dividing first leaves every representable interval alone, which is what a
|
||||
// timeout beyond the wire's range should do.
|
||||
let idle_cap = saturating_ns(config.idle_timeout() / 4);
|
||||
if idle_cap > 0 && requested.interval_ns > idle_cap {
|
||||
requested.interval_ns = idle_cap;
|
||||
}
|
||||
|
||||
requested
|
||||
}
|
||||
@@ -1,28 +1,62 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::{net::SocketAddr, time::Duration};
|
||||
|
||||
use irtt_proto::Params;
|
||||
|
||||
use crate::{clock::saturating_ns, config::ServerConfig};
|
||||
|
||||
/// How long past its negotiated maximum test duration a session is served
|
||||
/// before the next echo it answers carries the Close flag.
|
||||
///
|
||||
/// This is `irtt-rs` policy, not a protocol constant, chosen to match the
|
||||
/// additive two-second margin the clean evidence measured of the reference
|
||||
/// server at maxima of 500 ms, 1 s and 3 s. It is deliberately not
|
||||
/// configurable: a conforming client stops at the negotiated duration and never
|
||||
/// reaches the deadline at all, so the margin exists only to be forgiving of
|
||||
/// clients that do not, and an operator has the maximum itself to tune.
|
||||
const MAX_DURATION_CLOSE_GRACE: Duration = Duration::from_secs(2);
|
||||
|
||||
/// A live session created by an accepted open request.
|
||||
///
|
||||
/// A session records what identifies it, what was negotiated for it, and what
|
||||
/// its accepted echo requests have added up to. Lifetime state (activity,
|
||||
/// deadlines, expiry) and rate state belong to the slices that implement those
|
||||
/// behaviors, and are deliberately absent rather than present as placeholders.
|
||||
/// A session records what identifies it, what was negotiated for it, what its
|
||||
/// accepted echo requests have added up to, how much reply allowance it has
|
||||
/// left, and how long it has to live.
|
||||
///
|
||||
/// Deliberately crate-private. The remaining slices are all but certain to add
|
||||
/// and restructure fields here, and nothing outside the crate needs a session;
|
||||
/// The three mutable groups are kept apart because they move on different
|
||||
/// events: [`ReceiveState`] advances only for an echo that is answered,
|
||||
/// [`RateState`] for every echo that is judged, and [`LifetimeState`] for both —
|
||||
/// a rate-limited echo refreshes the idle deadline while advancing no statistic.
|
||||
///
|
||||
/// Deliberately crate-private. The remaining slices may still add and
|
||||
/// restructure fields here, and nothing outside the crate needs a session;
|
||||
/// publishing internal session state would buy nothing and commit us to a shape
|
||||
/// we are about to change.
|
||||
/// we may yet change.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct Session {
|
||||
peer: SocketAddr,
|
||||
params: Params,
|
||||
receive: ReceiveState,
|
||||
rate: RateState,
|
||||
lifetime: LifetimeState,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub(crate) fn new(peer: SocketAddr, params: Params) -> Self {
|
||||
/// Creates a session for parameters that have already been negotiated and
|
||||
/// found executable, as of monotonic instant `now_ns`.
|
||||
///
|
||||
/// Rate policy is derived from the configuration and the *negotiated*
|
||||
/// parameters once, here, rather than recomputed per echo — see
|
||||
/// [`RateState::new`] for why the two together decide it. The idle deadline
|
||||
/// starts running immediately: a session that never carries an echo request
|
||||
/// ages out like any other.
|
||||
pub(crate) fn new(
|
||||
peer: SocketAddr,
|
||||
params: Params,
|
||||
config: &ServerConfig,
|
||||
now_ns: i64,
|
||||
) -> Self {
|
||||
Self {
|
||||
rate: RateState::new(config, ¶ms, now_ns),
|
||||
lifetime: LifetimeState::opened_at(now_ns),
|
||||
peer,
|
||||
params,
|
||||
receive: ReceiveState::default(),
|
||||
@@ -69,6 +103,36 @@ impl Session {
|
||||
pub(crate) fn commit_receive_state(&mut self, next: ReceiveState) {
|
||||
self.receive = next;
|
||||
}
|
||||
|
||||
/// This session's reply allowance as it stands.
|
||||
pub(crate) fn rate_state(&self) -> RateState {
|
||||
self.rate
|
||||
}
|
||||
|
||||
/// Adopts the allowance computed for a judged echo request.
|
||||
///
|
||||
/// Unlike the receive state, this is committed for a *rate-limited* request
|
||||
/// too: declining to answer is itself bookkeeping the limiter has to keep,
|
||||
/// or the replenishment it just accounted for would be recomputed from a
|
||||
/// stale instant on the next request.
|
||||
pub(crate) fn commit_rate_state(&mut self, next: RateState) {
|
||||
self.rate = next;
|
||||
}
|
||||
|
||||
/// How long this session has to live, and when its test duration started.
|
||||
pub(crate) fn lifetime_state(&self) -> LifetimeState {
|
||||
self.lifetime
|
||||
}
|
||||
|
||||
/// Adopts the lifetime state computed for a request.
|
||||
pub(crate) fn commit_lifetime_state(&mut self, next: LifetimeState) {
|
||||
self.lifetime = next;
|
||||
}
|
||||
|
||||
/// Whether this session has gone `idle_timeout_ns` without an echo request.
|
||||
pub(crate) fn is_idle_expired(&self, now_ns: i64, idle_timeout_ns: i64) -> bool {
|
||||
self.lifetime.is_idle_expired(now_ns, idle_timeout_ns)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a session's accepted echo requests have added up to.
|
||||
@@ -138,3 +202,231 @@ impl ReceiveState {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One session's reply allowance, and the policy that replenishes it.
|
||||
///
|
||||
/// The externally visible behavior is what the clean specification's Section 9.3
|
||||
/// records: a session starts with its full burst allowance, spends one unit per
|
||||
/// echo it is answered, replenishes at one unit per refill interval and never
|
||||
/// exceeds the burst, and an echo arriving with no allowance gets no reply and
|
||||
/// advances no reception statistic. The specification is explicit that a
|
||||
/// token bucket is a *black-box inference* about the reference server, so this
|
||||
/// representation is an implementation choice, not a protocol requirement — any
|
||||
/// structure producing those outcomes would do.
|
||||
///
|
||||
/// The policy is copied in per session rather than read from the configuration
|
||||
/// per echo, because the refill interval depends on what that session
|
||||
/// negotiated. Sibling sessions therefore limit independently, as they do
|
||||
/// upstream.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct RateState {
|
||||
/// The most allowance this session may hold.
|
||||
burst: u32,
|
||||
/// How long one unit of allowance takes to accrue. Zero means no
|
||||
/// time-based throttling at all.
|
||||
interval_ns: i64,
|
||||
/// Allowance available now, never above `burst`.
|
||||
allowance: u32,
|
||||
/// The monotonic instant `allowance` was last accounted at.
|
||||
refilled_at_ns: i64,
|
||||
}
|
||||
|
||||
impl RateState {
|
||||
/// The full allowance a new session starts with, and the policy it will
|
||||
/// replenish under.
|
||||
///
|
||||
/// **The refill interval is the shorter of the configured minimum send
|
||||
/// interval and the interval this session actually negotiated.** Normally
|
||||
/// they are the configured minimum: a 10 ms minimum against a 1 s
|
||||
/// negotiated interval refills every 10 ms, which is the ordinary reference
|
||||
/// behavior. They differ only where negotiation's idle cap pulled the
|
||||
/// returned interval *below* the configured minimum — a 5 s minimum against
|
||||
/// an 8 s idle timeout returns 2 s — and there the negotiated value wins.
|
||||
///
|
||||
/// That is a deliberate `irtt-rs` divergence. The clean specification
|
||||
/// records the reference server replenishing on the configured minimum
|
||||
/// regardless, so a fully conforming client sending at the 2 s interval it
|
||||
/// was handed is rate-limited every time by a 5 s allowance. A server must
|
||||
/// not enforce a cadence it did not advertise, so this one refills at
|
||||
/// whichever cadence it told the client it would accept.
|
||||
///
|
||||
/// Zero on either side is not throttling. A zero configured minimum
|
||||
/// disables the time-based floor outright, and a session with no negotiated
|
||||
/// interval — an absent Interval, which only survives negotiation when the
|
||||
/// floor is zero as well — has no advertised cadence to honor, so the
|
||||
/// configured minimum stands alone. A zero refill interval means every
|
||||
/// otherwise admissible echo is allowed; a zero *burst* still means none is.
|
||||
fn new(config: &ServerConfig, params: &Params, now_ns: i64) -> Self {
|
||||
let configured = saturating_ns(config.min_send_interval());
|
||||
let interval_ns = if configured <= 0 || params.interval_ns <= 0 {
|
||||
configured
|
||||
} else {
|
||||
configured.min(params.interval_ns)
|
||||
};
|
||||
Self {
|
||||
burst: config.burst_allowance(),
|
||||
interval_ns,
|
||||
allowance: config.burst_allowance(),
|
||||
refilled_at_ns: now_ns,
|
||||
}
|
||||
}
|
||||
|
||||
/// Judges one echo request arriving at monotonic instant `now_ns`.
|
||||
///
|
||||
/// Pure, like [`ReceiveState::accepted`]: it returns the state the caller
|
||||
/// should commit rather than mutating, so an allowed request can have its
|
||||
/// reply built and encoded before any of the session moves. Both outcomes
|
||||
/// carry a next state, because replenishment is accounted for whether or not
|
||||
/// the request is served.
|
||||
pub(crate) fn charge(mut self, now_ns: i64) -> RateDecision {
|
||||
if self.burst == 0 {
|
||||
// No allowance exists to replenish into, so nothing accrues and
|
||||
// nothing is served. This is not a synonym for "unlimited".
|
||||
return RateDecision::Limited { next: self };
|
||||
}
|
||||
if self.interval_ns == 0 {
|
||||
// No time-based throttling: allowance never has to be spent,
|
||||
// because it never has to be waited for.
|
||||
return RateDecision::Allowed { next: self };
|
||||
}
|
||||
self.replenish(now_ns);
|
||||
if self.allowance == 0 {
|
||||
return RateDecision::Limited { next: self };
|
||||
}
|
||||
self.allowance -= 1;
|
||||
RateDecision::Allowed { next: self }
|
||||
}
|
||||
|
||||
/// Credits whatever whole intervals have elapsed, up to the burst.
|
||||
fn replenish(&mut self, now_ns: i64) {
|
||||
if self.allowance >= self.burst {
|
||||
// Already full. Idle time is not bankable, so the next unit is
|
||||
// measured from now rather than from a stretch the session spent
|
||||
// sending nothing.
|
||||
self.allowance = self.burst;
|
||||
self.refilled_at_ns = now_ns;
|
||||
return;
|
||||
}
|
||||
// Saturating, so a monotonic reading that somehow ran backwards yields a
|
||||
// negative elapsed time and credits nothing rather than panicking.
|
||||
let elapsed = now_ns.saturating_sub(self.refilled_at_ns);
|
||||
if elapsed < self.interval_ns {
|
||||
return;
|
||||
}
|
||||
let gained = elapsed / self.interval_ns;
|
||||
let allowance = self
|
||||
.allowance
|
||||
.saturating_add(u32::try_from(gained).unwrap_or(u32::MAX))
|
||||
.min(self.burst);
|
||||
self.refilled_at_ns = if allowance == self.burst {
|
||||
now_ns
|
||||
} else {
|
||||
// Carry the unspent remainder of the interval, so a session sending
|
||||
// at exactly its cadence does not lose a fraction of an interval on
|
||||
// every request and drift into being limited.
|
||||
self.refilled_at_ns
|
||||
.saturating_add(gained.saturating_mul(self.interval_ns))
|
||||
};
|
||||
self.allowance = allowance;
|
||||
}
|
||||
}
|
||||
|
||||
/// What the rate limiter decided about one echo request.
|
||||
///
|
||||
/// Both variants carry the state to commit; they differ in whether a reply is
|
||||
/// owed. `Limited` is a silent drop — the protocol has no rejection response —
|
||||
/// and it must leave the reception statistics exactly where they were.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum RateDecision {
|
||||
Allowed { next: RateState },
|
||||
Limited { next: RateState },
|
||||
}
|
||||
|
||||
/// How long a session has left, and when its test duration started running.
|
||||
///
|
||||
/// Both instants are monotonic. Lifetime decisions never consult the wall
|
||||
/// clock: a session must not outlive or predecease its deadline because an
|
||||
/// administrator or NTP stepped the host clock, and the wall readings a reply
|
||||
/// carries are a separate concern with the opposite requirement — that they be
|
||||
/// reported honestly, corrections and all.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct LifetimeState {
|
||||
/// When this session last did something that keeps it alive: it was opened,
|
||||
/// it had an echo answered, or it had one rate-limited.
|
||||
last_activity_ns: i64,
|
||||
/// When this session's first echo request was actually *served*, if one has
|
||||
/// been. The maximum test duration is measured from here.
|
||||
first_served_echo_ns: Option<i64>,
|
||||
}
|
||||
|
||||
impl LifetimeState {
|
||||
/// The state of a session at the moment it is created.
|
||||
///
|
||||
/// The idle deadline runs from the open, which is a deliberate `irtt-rs`
|
||||
/// divergence: the clean evidence records the reference server starting it
|
||||
/// at the first echo request instead, so a session that never carries one
|
||||
/// never expires. A session is created by a single unauthenticated
|
||||
/// datagram, so that is an unbounded-state hazard rather than an
|
||||
/// interoperability feature, and the specification recommends against
|
||||
/// reproducing it.
|
||||
fn opened_at(now_ns: i64) -> Self {
|
||||
Self {
|
||||
last_activity_ns: now_ns,
|
||||
first_served_echo_ns: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this session has gone `idle_timeout_ns` without an echo request.
|
||||
///
|
||||
/// The deadline is exact and the comparison inclusive: at
|
||||
/// `last_activity + idle_timeout` the session is already gone. `irtt-rs`
|
||||
/// deliberately does not reproduce the reference server's additional
|
||||
/// five-second grace, nor its lazy final reply to the first echo that finds
|
||||
/// an expired session. The specification records both as observed upstream
|
||||
/// policy and states explicitly that the one interoperability constraint is
|
||||
/// negative — a server must never *signal* expiry — which silence satisfies.
|
||||
///
|
||||
/// A zero timeout expires a session at the first evaluation. It is not a
|
||||
/// synonym for "never expire".
|
||||
fn is_idle_expired(&self, now_ns: i64, idle_timeout_ns: i64) -> bool {
|
||||
now_ns.saturating_sub(self.last_activity_ns) >= idle_timeout_ns
|
||||
}
|
||||
|
||||
/// Whether an echo arriving now is the one that should carry Close.
|
||||
///
|
||||
/// The deadline is `first served echo + maximum + `
|
||||
/// [`MAX_DURATION_CLOSE_GRACE`], so it does not exist at all until an echo
|
||||
/// has actually been served: neither the open, nor an echo that was
|
||||
/// rejected, nor one that was rate-limited starts it. That origin is
|
||||
/// externally measured behavior, not an inference.
|
||||
pub(crate) fn max_duration_reached(&self, now_ns: i64, max_duration_ns: i64) -> bool {
|
||||
self.first_served_echo_ns.is_some_and(|first| {
|
||||
let deadline = first
|
||||
.saturating_add(max_duration_ns)
|
||||
.saturating_add(saturating_ns(MAX_DURATION_CLOSE_GRACE));
|
||||
now_ns >= deadline
|
||||
})
|
||||
}
|
||||
|
||||
/// The state after an echo that kept the session alive without being
|
||||
/// served — which is to say, one that was rate-limited.
|
||||
///
|
||||
/// This is the one drop class the clean evidence records as refreshing the
|
||||
/// idle deadline, and it was established against every other tested class,
|
||||
/// which does not. It does **not** move the maximum-duration origin: a
|
||||
/// request that was never answered cannot start the test.
|
||||
pub(crate) fn refreshed(mut self, now_ns: i64) -> Self {
|
||||
self.last_activity_ns = now_ns;
|
||||
self
|
||||
}
|
||||
|
||||
/// The state after an echo that is about to be answered.
|
||||
///
|
||||
/// Sets the maximum-duration origin if this is the session's first served
|
||||
/// echo, and refreshes the idle deadline either way.
|
||||
pub(crate) fn served(mut self, now_ns: i64) -> Self {
|
||||
self.last_activity_ns = now_ns;
|
||||
self.first_served_echo_ns.get_or_insert(now_ns);
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -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,9 +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,168 @@
|
||||
//! 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::Params;
|
||||
|
||||
use super::support::{
|
||||
client_params, core_with_tokens, expect_normal_open_reply, open_request, peer, ScriptedTokens,
|
||||
};
|
||||
use crate::ServerConfig;
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ 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, ScriptedTokens, KEY,
|
||||
open_request_with_raw_params, param_int, peer, unthrottled, ScriptedTokens, KEY,
|
||||
};
|
||||
use crate::ServerConfig;
|
||||
|
||||
@@ -200,7 +200,9 @@ fn an_authenticated_no_test_reply_is_authenticated_and_still_creates_nothing() {
|
||||
|
||||
#[test]
|
||||
fn an_empty_no_test_open_is_answered() {
|
||||
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
// 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;
|
||||
|
||||
@@ -2,7 +2,7 @@ use irtt_proto::{Clock, Params, ReceivedStats, ServerFill, StampAt};
|
||||
|
||||
use super::support::{
|
||||
client_params, core_with_tokens, expect_normal_open_reply, open_request,
|
||||
open_request_with_raw_params, param_int, peer, ScriptedTokens, KEY,
|
||||
open_request_with_raw_params, param_int, peer, unthrottled, ScriptedTokens, KEY,
|
||||
};
|
||||
use crate::{ServerConfig, DEFAULT_MAX_PACKET_LENGTH};
|
||||
|
||||
@@ -84,7 +84,11 @@ fn any_requested_protocol_version_is_accepted_and_answered_with_one() {
|
||||
|
||||
#[test]
|
||||
fn an_empty_parameter_payload_is_answered_with_only_a_version() {
|
||||
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
// 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))
|
||||
@@ -114,7 +118,7 @@ fn an_empty_parameter_payload_is_answered_with_only_a_version() {
|
||||
|
||||
#[test]
|
||||
fn unknown_parameter_tags_are_ignored_and_not_reflected_in_the_reply() {
|
||||
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
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));
|
||||
@@ -187,12 +191,14 @@ fn selecting_timestamps_without_a_clock_is_silently_refused() {
|
||||
|
||||
#[test]
|
||||
fn current_non_length_parameters_survive_negotiation_unchanged() {
|
||||
// Duration, interval, statistics, timestamps, DSCP and server fill are the
|
||||
// values a later restriction-policy slice will deliberately start changing.
|
||||
// Pinning them now makes that a visible decision rather than an accident.
|
||||
// Version and an oversized packet length are already restricted, and have
|
||||
// their own tests; the length here is deliberately under the default
|
||||
// maximum so that this test says nothing about the packet-length policy.
|
||||
// Statistics, timestamps, DSCP and server fill are the values a later
|
||||
// restriction-policy slice will deliberately start changing. Pinning them
|
||||
// now makes that a visible decision rather than an accident. 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.
|
||||
//
|
||||
// This is about what *negotiation* rewrites, which is a separate question
|
||||
// from whether the core will acknowledge the result: an open whose
|
||||
@@ -212,7 +218,9 @@ fn current_non_length_parameters_survive_negotiation_unchanged() {
|
||||
}),
|
||||
};
|
||||
|
||||
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
// 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()
|
||||
|
||||
@@ -2,7 +2,7 @@ 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, ScriptedTokens,
|
||||
param_int, param_server_fill, peer, unthrottled, ScriptedTokens,
|
||||
};
|
||||
use crate::ServerConfig;
|
||||
|
||||
@@ -70,7 +70,12 @@ fn malformed_or_out_of_range_parameters_are_dropped_without_a_session() {
|
||||
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.
|
||||
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
//
|
||||
// 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(
|
||||
@@ -138,7 +143,10 @@ fn valid_optional_parameter_values_survive_unchanged() {
|
||||
},
|
||||
),
|
||||
] {
|
||||
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
// 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))
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ use std::{
|
||||
collections::VecDeque,
|
||||
net::SocketAddr,
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use irtt_proto::{
|
||||
@@ -20,6 +21,22 @@ use crate::{
|
||||
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()
|
||||
}
|
||||
@@ -77,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>>>,
|
||||
@@ -114,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.
|
||||
@@ -121,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))
|
||||
}
|
||||
@@ -135,13 +199,47 @@ pub(crate) fn core_with_sources(
|
||||
/// 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 = ServerConfig::default();
|
||||
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<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,
|
||||
@@ -229,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