feat(server): enforce rate and session lifetime

This commit is contained in:
Nils Andreas Svee committed 2026-08-13 00:45:40 +07:00
1 parent 683b52d334
commit c2994f64a4
10 files changed
+1506 -70

No files matched your search

+301 -9
View File
@@ -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, &params, 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
}
}