irtt-server 0.5.3

IRTT-compatible Rust server library
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
use std::{net::SocketAddr, time::Duration};

use irtt_proto::Params;

use crate::{clock::saturating_ns, config::ServerConfig, fill::FillMode};

/// 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, what its
/// accepted echo requests have added up to, how much reply allowance it has
/// left, and how long it has to live.
///
/// 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 may yet change.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Session {
    peer: SocketAddr,
    params: Params,
    fill: FillMode,
    receive: ReceiveState,
    rate: RateState,
    lifetime: LifetimeState,
}

impl Session {
    /// 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.
    ///
    /// `fill` is the *effective* fill negotiation settled on, which the session
    /// has to be told rather than left to infer from `params` later: an absent
    /// or empty descriptor keeps its representation on the wire while the
    /// session uses this server's default fill, and that distinction only
    /// exists at the moment of negotiation.
    pub(crate) fn new(
        peer: SocketAddr,
        params: Params,
        fill: FillMode,
        config: &ServerConfig,
        now_ns: i64,
    ) -> Self {
        Self {
            rate: RateState::new(config, &params, now_ns),
            lifetime: LifetimeState::opened_at(now_ns),
            peer,
            params,
            fill,
            receive: ReceiveState::default(),
        }
    }

    /// The exact source endpoint the open request arrived from.
    ///
    /// A session is bound to this endpoint, not merely to its address: the
    /// address family, the address and the UDP source port all form part of its
    /// identity, as — by `irtt-rs` policy rather than observed behavior — does
    /// the IPv6 scope identifier. Close and echo processing both require a
    /// token match *and* an endpoint match before touching the session;
    /// `same_endpoint` in `core` is where the comparison and its reasoning
    /// live.
    ///
    /// The address is stored exactly as received, including the flow label that
    /// identity ignores, so a reply goes back to the endpoint the request
    /// actually came from.
    pub(crate) fn peer(&self) -> SocketAddr {
        self.peer
    }

    /// The negotiated parameters the server returned for this session and
    /// enforces for it.
    ///
    /// Echo replies are laid out and sized from these, never from the request
    /// that provoked them.
    pub(crate) fn params(&self) -> &Params {
        &self.params
    }

    /// How this session's echo replies fill their payload region.
    ///
    /// This is the *effective* fill, which is not always what
    /// [`params`](Self::params) reports: a client that expressed no preference
    /// keeps its absent or empty descriptor in the negotiated parameters and is
    /// served this server's default fill. Only a descriptor this server could
    /// not honor is rewritten in `params`.
    pub(crate) fn fill(&self) -> &FillMode {
        &self.fill
    }

    /// What this session's accepted echo requests have added up to so far.
    pub(crate) fn receive_state(&self) -> ReceiveState {
        self.receive
    }

    /// Adopts the state computed for an accepted echo request.
    ///
    /// Kept separate from [`ReceiveState::accepted`] on purpose. The transition
    /// is pure and this is the irreversible step, so echo handling runs every
    /// fallible part — building and encoding the reply — before calling it, and
    /// a reply that fails to encode leaves the session exactly as it was.
    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.
///
/// The semantics are those of the clean server specification, Section 10, and
/// the input → output vectors in `test-vectors/SERVER_BEHAVIORAL_VECTORS.md`
/// Section 1. Both values are per session, start at zero, and move only for a
/// request that reaches the reply stage: a datagram dropped for its size, its
/// authentication, an unknown token or a foreign endpoint leaves them alone.
///
/// The state is maintained whatever the session negotiated. Which of the two
/// values actually appears in a reply is a separate question, answered by the
/// negotiated `ReceivedStats` when the reply is built.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) struct ReceiveState {
    /// Accepted echo requests, including the one currently being answered.
    pub(crate) count: u32,
    /// The received window: bit 0 is the request being answered and bit *k* is
    /// the request with sequence number (current − *k*), for 1 ≤ *k* ≤ 63.
    pub(crate) window: u64,
    /// The sequence number the previous accepted request carried, which the
    /// next transition is measured against. `None` before the session's first.
    last_sequence: Option<u32>,
}

impl ReceiveState {
    /// The state after accepting a request carrying `sequence`.
    ///
    /// Pure, so the caller can compute the next state, build and encode a reply
    /// from it, and only then commit it.
    ///
    /// The window transition is the observed one, and it is **not** a
    /// selective-acknowledgement bitmap: history is shifted along by the
    /// unsigned distance from the previous accepted sequence number, and a
    /// distance it cannot represent throws all of it away. The consequence is
    /// the distinctive part — because the distance is unsigned, a *late or
    /// reordered* request produces a very large one, so it resets the window to
    /// `0x1` instead of setting an old bit, and the history it discards is
    /// never recovered. That late sequence number then becomes the reference
    /// for the next transition like any other.
    pub(crate) fn accepted(self, sequence: u32) -> Self {
        let window = match self.last_sequence {
            // The first accepted request of a session reports only itself,
            // whatever its sequence number.
            None => 0x1,
            Some(last) => match sequence.wrapping_sub(last) {
                // A duplicate of the most recent sequence number leaves the
                // window unchanged, since bit 0 is already set for it.
                0 => self.window | 1,
                // In order, or a gap the window can still represent: shift the
                // history along by the distance and record this request.
                distance @ 1..=63 => (self.window << distance) | 1,
                // A gap of 64 or more discards every earlier bit — and so does
                // any late or reordered request, which lands here because the
                // unsigned distance wrapped.
                _ => 0x1,
            },
        };
        Self {
            // The wire field is 32 bits. No clean evidence reaches 2^32
            // requests in one session, so the wrap is not verified behavior: it
            // is simply what maintaining a fixed-width counter means, and it is
            // chosen over a debug-build overflow panic a peer could drive.
            count: self.count.wrapping_add(1),
            window,
            last_sequence: Some(sequence),
        }
    }
}

/// 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
    }
}