moqtap_proxy/shape/mod.rs
1//! Egress shaping — the configuration a scenario author writes, and the
2//! pure primitives the scheduler is built from.
3//!
4//! A [`ShapeProfile`] describes what one session's *media* egress is
5//! allowed to do: named token buckets, class rules that aim a [`Matcher`]
6//! at a bucket, one bounded-queue policy, and a [`Discipline`] that
7//! arbitrates between classes competing for the same bucket. Control
8//! streams are never shaped — pacing SUBSCRIBE and ANNOUNCE behind a video
9//! bucket would stall MoQT's normal steady state and make an idle control
10//! stream look like a dead session.
11//!
12//! # Why this module is `pub`
13//!
14//! Unlike the engine internals (`egress`, `exec`, `release_timer`), a
15//! scenario author *constructs* these types, so they are public and every
16//! item below carries a rustdoc comment.
17//!
18//! # The two constructor shapes, and why they differ
19//!
20//! [`ShapeProfile`] has **private fields and a fallible constructor**. A
21//! mis-typed bucket name would otherwise be a silently inert class —
22//! configuration that looks applied and does nothing, which is exactly the
23//! failure this module exists to make impossible. [`ShapeProfile::try_new`]
24//! rejects it, so an invalid profile cannot reach a session and there is no
25//! runtime *your config was rejected* path to miss.
26//!
27//! The four config structs it is built from — [`BucketConfig`],
28//! [`ClassRule`], [`Matcher`] and [`QueueConfig`] — are the opposite:
29//! all-public fields and `#[non_exhaustive]` **with** a [`Default`],
30//! exactly as [`EgressConfig`](crate::action::EgressConfig) is. The pairing
31//! is load-bearing rather than stylistic: `#[non_exhaustive]` on its own
32//! makes a struct unconstructible outside this crate, because
33//! struct-expression *and* functional-update syntax are both illegal there
34//! — the entire public configuration surface would be unreachable from an
35//! integration-test crate and from every scenario author's code.
36//!
37//! Note precisely what the `Default` buys, because it is one step less than
38//! it looks: `..Default::default()` is **also** `E0639` outside this crate,
39//! so an outside caller writes `let mut m = Matcher::default();` followed by
40//! per-field assignment — which is what `tests/actions_shaping.rs` does at
41//! every construction site. What the `Default` provides is a *value* to
42//! start from, not a syntax. Inside this crate both forms compile, which is
43//! why the unit tests below use the shorter one; a sentence claiming the
44//! functional-update form works for a scenario author was measured false
45//! (11 × `E0639` out of tree, on all four structs).
46//!
47//! # State of the module
48//!
49//! The types, [`Matcher::matches`], [`ShapeProfile::try_new`]'s validation
50//! and the pure token bucket ([`charge`]) landed first, with their own unit
51//! tests, so the scheduler that consumes them lands against arithmetic that
52//! is already gated.
53//!
54//! A configured [`ShapeProfile`] arms framing on its own
55//! (`ProxySessionConfig::shape`), [`ShapeStats`] is recorded and readable
56//! through
57//! [`ProxySession::shape_stats`](crate::session::ProxySession::shape_stats),
58//! **admission** runs — per-unit classification through [`Matcher`], the
59//! per-stream queue depth and all three [`Overflow`] policies — and so does
60//! **release**: every shaped unit is queued rather than written inline, its
61//! class's token bucket is debited at `PendingQueue::pop_next_due`, the
62//! configured [`Discipline`] arbitrates between classes sharing a bucket,
63//! and [`Expiry`] decides what becomes of a unit that outlives `max_hold`.
64//!
65//! The same figures are kept a second time for a whole proxy. [`ProxyStats`]
66//! — [`LegStats`], [`SessionStats`] and the class rows — is read through
67//! [`ProxyControl::stats`](crate::control::ProxyControl::stats) and covers
68//! every session the proxy has accepted, including the ones that have already
69//! ended, so it is cumulative where `ProxySession::shape_stats` is one
70//! session's own. It is charged by the same writers, forwarded from inside
71//! each one, so no figure can reach a session's rows and miss the proxy's.
72//! The one shape difference is worth knowing before reading a cell: a
73//! session's totals carry a direction only, while a proxy's carry a leg *and*
74//! a direction, because a proxy holds two connections and a byte crosses
75//! both. [`LegStats`] states which cell each measurement lands in.
76//!
77//! Two things are deliberately outside that: **control streams**, which
78//! install no scheduler at all, and **teardown**, which drains ignoring
79//! release times so a bucket can never gate a mirrored reset.
80//!
81//! **An object too large for the framer to buffer is outside it as well, and
82//! says so.** Such an object has no `ObjectMeta`, so no rule can name it, so no
83//! bucket charges it and it is granted unconditionally — one object can
84//! therefore cross a class's rate whole. Measured: a 4 MiB object crossed in
85//! 800 ms against a class whose bucket was configured at zero bytes per second.
86//! The bytes are accounted on [`ShapeStats::unshapeable`] and the session
87//! reports `Impairment{ShapeUnpacedObject}`, once per stream, naming the class
88//! the stream's other units are charged to — because *my 500 kbps cap was
89//! breached by one large segment* is otherwise a hole in the accounting with
90//! nothing to attribute it to.
91//!
92//! **Datagrams are policed rather than paced**, which is a different
93//! operation and not a lesser one. `forward_datagrams` classifies each
94//! datagram through [`Matcher::matches_datagram`], asks its class's bucket
95//! for the bytes, and **discards** what the bucket refuses instead of
96//! queuing it. Nothing on that path delays anything, and nothing should: a
97//! FIFO would impose a delivery order the protocol does not have, and a
98//! datagram has neither a successor written against it nor a stream whose
99//! object IDs would move behind a hole — which is exactly what makes
100//! dropping the arriving unit sound here and unsound for a queued stream
101//! unit.
102//!
103//! What follows from that shape, and is worth knowing before reading a
104//! figure: [`QueueConfig`] is **not consulted** for a datagram. Neither
105//! depth binds it and no [`Overflow`] policy decides it, because it is never
106//! queued — the bucket is the whole of the decision. A profile that shapes
107//! subgroup streams and polices datagrams reads its queue policy for the
108//! first and not for the second.
109//!
110//! **This is settled rather than pending, and the sharp edge is worth stating
111//! outright:** the bucket can answer *not now, but at this instant* — the
112//! same answer that defers a stream unit — and on the datagram path that
113//! answer is discarded like every other refusal. A datagram over a live rate
114//! is dropped where it arrived, not held until the instant its own bucket
115//! named.
116//!
117//! **If what is wanted is smoothing, reach for `quinn-netem`.** It delays,
118//! jitters and reorders at the socket, under the whole connection, which is
119//! the scope a link-level queue has: a bottleneck queues by link, not by
120//! track, and a router does not know which track a datagram belongs to.
121//! Class-aware policing is a real box — an operator rate-limiter drops over
122//! rate — while class-aware smoothing is a scheduler *inside* a router, which
123//! is not a condition a player is ever placed in. So netem's not being
124//! class-aware is the right scope for it rather than a gap in it, and the
125//! division is: **a rate on one track is a class over a bucket, and belongs
126//! here; a congested path is netem.**
127//!
128//! The framed sites keep their `Delay` and `Hold` because a stream **has** a
129//! delivery order — holding object N and then N+1 preserves a guarantee the
130//! protocol makes, where holding two datagrams would manufacture one.
131//!
132//! There is no seam a datagram never reaches. There was one — a report a
133//! `Fetch`-aimed class made on drafts 18 and 19, where the framer bypassed
134//! every fetch stream before any `ObjectMeta` existed — and it went when
135//! those streams became readable. Every unmatchable rule now reports from a
136//! unit that arrived, through `Scheduler::classify` or its datagram sibling.
137//!
138//! # What is still owed
139//!
140//! [`BucketConfig::ceil_bps`] is accepted and never borrowed against: a
141//! profile setting it above `rate_bps` measures a flat `rate_bps`.
142//!
143//! Nothing else, and there used to be more: five reported fields here
144//! snapshotted as a constant zero. Two of them counted what a **hook** does,
145//! which needs no profile at all while every figure on this page is gated on
146//! one, and they are
147//! [`Counters::units_delayed`](crate::instrument::Counters::units_delayed)
148//! and
149//! [`Counters::objects_truncated`](crate::instrument::Counters::objects_truncated)
150//! now. The other three were `Duration` totals; [`ClassStats`] says why this
151//! page carries no duration at all.
152//!
153//! Separately, and not the same kind of zero: of the five figures a
154//! [`DirectionStats`] carries, only `objects_seen` and `bytes_shaped` are
155//! measured at both crossings, so the three event figures read zero in a
156//! **departure** cell of [`ProxyStats::per_leg`]. [`LegStats`] says which
157//! cell is which.
158
159mod bucket;
160mod matcher;
161mod scheduler;
162mod stats;
163
164pub use bucket::{charge, BucketConfig, BucketState, Grant};
165pub use matcher::{MatchKind, Matcher, MatcherField, RangeSet};
166pub use stats::{ClassStats, DirectionStats, LegStats, ProxyStats, SessionStats, ShapeStats};
167
168pub(crate) use scheduler::{Acquire, Admission, Class, QueueDepth, Scheduler};
169pub(crate) use stats::{ProxyRecorder, ShapeRecorder};
170
171use std::collections::HashSet;
172use std::time::Duration;
173
174use crate::types::ProxySide;
175
176/// A complete egress shaping configuration for one session.
177///
178/// Constructed only through [`ShapeProfile::try_new`], which validates the
179/// combination — an invalid profile cannot reach a session, so there is no
180/// runtime *your config was rejected* path to miss.
181///
182/// Not `#[non_exhaustive]`: the fields are private, so the attribute would
183/// add nothing a caller could observe.
184///
185/// # Reading one from a file
186///
187/// Under the non-default `serde` feature this type serializes and
188/// deserializes, and the two directions are **not symmetric**. Serializing is
189/// a derive over the private fields, which is safe because writing a profile
190/// out cannot make an invalid one. Deserializing goes
191/// `#[serde(try_from = "ShapeProfileSpec")]`, through a public-field mirror
192/// whose `TryFrom` calls [`ShapeProfile::try_new`].
193///
194/// The detour is the whole point. A derived `Deserialize` would reach these
195/// private fields directly and bypass every one of the seven validations below
196/// — including [`ShapeError::UnknownBucket`], where a file naming a bucket
197/// that does not exist would parse, arm, report shaping and shape nothing.
198/// Routing through the mirror means there is no deserialization path that
199/// skips the constructor, and no consumer has to remember to convert.
200#[derive(Debug, Clone, PartialEq)]
201#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
202#[cfg_attr(feature = "serde", serde(try_from = "ShapeProfileSpec"))]
203pub struct ShapeProfile {
204 buckets: Vec<BucketConfig>,
205 classes: Vec<ClassRule>,
206 queue: QueueConfig,
207 discipline: Discipline,
208}
209
210impl ShapeProfile {
211 /// Validate a profile and build it, or say exactly what is wrong.
212 ///
213 /// The seven rejections, in the order they are checked:
214 ///
215 /// 1. [`ShapeError::EmptyQueue`] — a queue that can hold nothing.
216 /// 2. [`ShapeError::NoClasses`] — a profile with no class rules at all,
217 /// which is a shaping profile that shapes nothing.
218 /// 3. [`ShapeError::DuplicateClassName`] — class names index the
219 /// statistics, so duplicates make them unattributable.
220 /// 4. [`ShapeError::UnknownBucket`] — a class naming a bucket that is
221 /// not in `buckets`; the silently-inert class this constructor
222 /// exists to prevent.
223 /// 5. [`ShapeError::EgressSideInMatcher`] — a matcher keyed on an
224 /// egress side, which no hook site ever sees.
225 /// 6. [`ShapeError::ZeroWeight`] — a zero weight under
226 /// [`Discipline::WeightedRoundRobin`], which is a class that can
227 /// never be scheduled.
228 /// 7. [`ShapeError::InertMatcher`] — a matcher key naming an *empty set
229 /// of values*, which is a class that can never claim a unit.
230 ///
231 /// The second is checked **outside** the loop below, and that is the
232 /// point of it: every other class rule is checked *inside* a
233 /// `for class in &classes`, and a loop over nothing runs no checks at
234 /// all. A profile with no classes was therefore the one shape that could
235 /// pass every rule here by not being subject to any of them.
236 ///
237 /// Duplicate *bucket* names are not an error: two identical entries
238 /// resolve to the same bucket and the first one wins, which is what a
239 /// caller who wrote the name twice meant. Only class names index
240 /// anything.
241 ///
242 /// # What this constructor cannot see, and why the line is there
243 ///
244 /// Every check above is a property of the **configuration alone**. What
245 /// it deliberately does not attempt is anything that depends on the
246 /// draft or on the traffic, and there are two such faults; both are
247 /// reported during the run instead, because a rejection here has to be
248 /// right for *every* session the profile could be used in.
249 ///
250 /// A key the wire does not carry on this draft is
251 /// `Impairment{ShapeRuleUnmatchable}` — `try_new` has no draft. And a
252 /// [`BucketConfig::burst_bytes`] smaller than the objects a class
253 /// actually sees is
254 /// `Impairment{ShapeBurstBelowUnit}` — `try_new` has the burst but not
255 /// the object sizes, and the sizes are what decide. The second is worth
256 /// the attention because its silent form is so plausible: a burst below
257 /// one object makes every unit leave at its `max_hold` clamp, at a
258 /// throughput with no relation to the rate that was configured, and
259 /// before the report existed the only signal was the one an ordinary
260 /// rate-limited class produces.
261 pub fn try_new(
262 buckets: Vec<BucketConfig>,
263 classes: Vec<ClassRule>,
264 queue: QueueConfig,
265 discipline: Discipline,
266 ) -> Result<Self, ShapeError> {
267 if queue.depth_bytes == 0 || queue.depth_objects == 0 {
268 return Err(ShapeError::EmptyQueue);
269 }
270 // Before the loop, because the loop is what every other rule lives
271 // in and an empty list is the one input it cannot judge.
272 if classes.is_empty() {
273 return Err(ShapeError::NoClasses);
274 }
275
276 let mut seen: HashSet<&str> = HashSet::with_capacity(classes.len());
277 for class in &classes {
278 if !seen.insert(class.name.as_str()) {
279 return Err(ShapeError::DuplicateClassName { name: class.name.clone() });
280 }
281 if !buckets.iter().any(|b| b.name == class.bucket) {
282 return Err(ShapeError::UnknownBucket {
283 class: class.name.clone(),
284 bucket: class.bucket.clone(),
285 });
286 }
287 if matches!(
288 class.matcher.side,
289 Some(ProxySide::ProxyToClient) | Some(ProxySide::ProxyToRelay)
290 ) {
291 return Err(ShapeError::EgressSideInMatcher { class: class.name.clone() });
292 }
293 if discipline == Discipline::WeightedRoundRobin && class.weight == 0 {
294 return Err(ShapeError::ZeroWeight { class: class.name.clone() });
295 }
296 if let Some(key) = class.matcher.inert_key() {
297 return Err(ShapeError::InertMatcher { class: class.name.clone(), key });
298 }
299 }
300
301 Ok(Self { buckets, classes, queue, discipline })
302 }
303
304 /// The configured buckets, in the order they were given.
305 pub fn buckets(&self) -> &[BucketConfig] {
306 &self.buckets
307 }
308
309 /// The configured class rules, in the order they were given — which is
310 /// also the order the statistics snapshot reports them in, and the
311 /// order [`Discipline::Fifo`] tie-breaks on.
312 pub fn classes(&self) -> &[ClassRule] {
313 &self.classes
314 }
315
316 /// The per-stream queue policy.
317 pub fn queue(&self) -> &QueueConfig {
318 &self.queue
319 }
320
321 /// How classes competing for one bucket are arbitrated.
322 pub fn discipline(&self) -> Discipline {
323 self.discipline
324 }
325}
326
327/// Why a [`ShapeProfile`] could not be built.
328///
329/// `#[non_exhaustive]` and no `Default`: nobody constructs an error, and a
330/// later release adding a further reason must not be a breaking change.
331#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
332#[non_exhaustive]
333pub enum ShapeError {
334 /// Two classes share a name. Class names index the stats, so they must
335 /// be unique or the stats are unattributable.
336 #[error("duplicate class name: {name}")]
337 DuplicateClassName {
338 /// The name that appeared more than once.
339 name: String,
340 },
341 /// A class names a bucket that is not in `buckets`.
342 #[error("class {class} names unknown bucket {bucket}")]
343 UnknownBucket {
344 /// The class holding the dangling reference.
345 class: String,
346 /// The bucket name that matches no [`BucketConfig`].
347 bucket: String,
348 },
349 /// A matcher's `side` is an egress label. Hook sites only ever see
350 /// `ClientToProxy` / `RelayToProxy`, so such a rule matches nothing —
351 /// rejected here rather than left to look like a working rule that
352 /// never fires.
353 #[error("class {class} matches on an egress side, which no hook site sees")]
354 EgressSideInMatcher {
355 /// The class whose matcher named an egress side.
356 class: String,
357 },
358 /// [`Discipline::WeightedRoundRobin`] with a zero weight: a class that
359 /// can never be scheduled.
360 #[error("class {class} has weight 0 under WeightedRoundRobin")]
361 ZeroWeight {
362 /// The class whose weight is zero.
363 class: String,
364 },
365 /// [`QueueConfig::depth_objects`] or [`QueueConfig::depth_bytes`] is
366 /// zero, so the queue could admit nothing.
367 #[error("queue depth is zero in bytes or in objects")]
368 EmptyQueue,
369 /// The profile declares no class rules, so it is a shaping profile that
370 /// shapes nothing.
371 ///
372 /// Every unit such a profile sees falls to `Class::Default`, which is
373 /// unpaced: no bucket claims it, no discipline arbitrates it and the
374 /// queue releases it as soon as it reaches the head. A session
375 /// configured with one therefore frames every object — because a profile
376 /// arms framing on its own — pays for the classification and the queue,
377 /// reports itself as shaping, and delivers at line rate. Nothing in
378 /// [`crate::shape::ShapeStats`] distinguishes it from a profile whose
379 /// classes never matched.
380 ///
381 /// # It also used to reach further than the session that carried it
382 ///
383 /// A proxy sizes its class rows once, from the first shaped session it
384 /// accepts, because a class is an index into the class list of the
385 /// scheduler that produced it and rows that could be resized underneath
386 /// a running session would relabel every figure in them. A classless
387 /// profile arriving first would have installed **no rows at all**, and
388 /// every classed session accepted afterwards — for the life of the proxy
389 /// — would have found rows it did not match and charged the default one:
390 /// every number right, every label gone, unrecoverable without a
391 /// restart. That is guarded a second time where the sizing happens, but
392 /// the guard is a repair at the far end of the pipe; this is the profile
393 /// never existing.
394 ///
395 /// A caller who wants the queue policy and no pacing writes one class
396 /// claiming everything, over a bucket with no rate — an explicitly
397 /// unshaped class, which has a name and a row of its own and says in the
398 /// configuration what a missing class list only implied.
399 #[error(
400 "a shaping profile with no classes shapes nothing: every unit falls to the unpaced \
401 default class. Declare a class over a rate-less bucket if that is what was meant"
402 )]
403 NoClasses,
404 /// A [`Matcher`] key names an **empty set of values**, so the class can
405 /// never claim a unit — on any draft, from any traffic.
406 ///
407 /// The three shapes this catches, all of which were accepted before:
408 /// a [`RangeSet`] built from an inverted range (`RangeSet::new` drops
409 /// `start > end`, leaving an empty set whose `contains` is always
410 /// `false`), an empty [`Matcher::priority`] range such as `200..=100`,
411 /// and [`Matcher::every_nth`] with `n == 0`.
412 ///
413 /// Distinct from
414 /// [`ImpairmentKind::ShapeRuleUnmatchable`](crate::event::ImpairmentKind::ShapeRuleUnmatchable),
415 /// and the distinction is *when the answer exists*: a rule keyed on a
416 /// field this draft does not carry can only be judged against a running
417 /// session, so it is reported; an empty value set is a property of the
418 /// configuration by itself, so it is rejected before a session starts.
419 /// Rejecting is strictly the better answer where it is available —
420 /// there is no run to read the report from.
421 #[error("class {class} keys on {key}, which names no value at all")]
422 InertMatcher {
423 /// The class whose matcher can never claim anything.
424 class: String,
425 /// The key that names the empty set, spelled as the
426 /// [`Matcher`] field is: one of `track_alias`, `group_id`,
427 /// `subgroup_id`, `object_id`, `priority`, `every_nth`.
428 key: &'static str,
429 },
430}
431
432/// A matcher plus what to do with what it matches.
433///
434/// `#[non_exhaustive]` *with* a [`Default`] — see the module doc for why
435/// the pairing is required rather than stylistic. The default is an
436/// unnamed class that claims every unit and names no bucket, so it is
437/// always edited before use; note that a defaulted `weight` of zero is
438/// rejected under [`Discipline::WeightedRoundRobin`].
439///
440/// In the written form `name` and `bucket` are required and the other three
441/// default, because the two that are required are the two whose defaults are
442/// wrong rather than merely empty: an unnamed class collides with the next
443/// unnamed class as [`ShapeError::DuplicateClassName`], and a class naming no
444/// bucket at all is [`ShapeError::UnknownBucket`] against the empty string.
445/// Both are refusals about a key the author never wrote.
446#[derive(Debug, Clone, PartialEq, Eq, Default)]
447#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
448#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
449#[non_exhaustive]
450pub struct ClassRule {
451 /// The class name. Unique across the profile, and the label under
452 /// which this class's statistics are reported.
453 pub name: String,
454 /// Which units this class claims.
455 #[cfg_attr(feature = "serde", serde(default))]
456 pub matcher: Matcher,
457 /// The [`BucketConfig::name`] this class charges against. Several
458 /// classes may share one bucket, which is what makes [`Discipline`]
459 /// mean anything.
460 pub bucket: String,
461 /// [`Discipline::StrictPriority`] orders classes by this; higher wins.
462 ///
463 /// Distinct from MoQT's `publisher_priority`, which
464 /// [`Matcher::priority`] keys on: this one is the scheduler's, and it
465 /// is always present.
466 #[cfg_attr(feature = "serde", serde(default))]
467 pub priority: u8,
468 /// [`Discipline::WeightedRoundRobin`] shares a bucket by this. Zero is
469 /// rejected under that discipline and ignored under the other two.
470 #[cfg_attr(feature = "serde", serde(default))]
471 pub weight: u16,
472}
473
474/// The per-stream queue policy: how deep, how long, and what happens at
475/// each limit.
476///
477/// `#[non_exhaustive]` *with* a [`Default`] — see the module doc.
478///
479/// The `Default` is **hand-written, not derived**: a derived one would
480/// give `depth_bytes == 0` and `depth_objects == 0`, which
481/// [`ShapeProfile::try_new`] rejects as [`ShapeError::EmptyQueue`] — so
482/// `QueueConfig::default()` would be a value that cannot be used, and the
483/// `..Default::default()` idiom this type is built for would fail on every
484/// profile that did not restate both depths.
485///
486/// That hand-written `Default` is also what the written form defaults every
487/// key to, so a scenario file may omit `queue` entirely or name only the one
488/// knob it cares about. It is the one config in this module where the derived
489/// default would be the unusable value and the written default is therefore
490/// worth having.
491#[derive(Debug, Clone, PartialEq, Eq)]
492#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
493#[cfg_attr(feature = "serde", serde(default, deny_unknown_fields))]
494#[non_exhaustive]
495pub struct QueueConfig {
496 /// Bytes one stream's queue may hold. Defaults to 1 MiB, matching
497 /// [`EgressConfig::max_pending_bytes`](crate::action::EgressConfig::max_pending_bytes).
498 pub depth_bytes: usize,
499 /// Objects one stream's queue may hold. Defaults to 256 — a limit the
500 /// byte depth does not imply, since a queue of small objects reaches
501 /// neither.
502 pub depth_objects: usize,
503 /// Deadline for a queued object, per class. `None` inherits
504 /// [`EgressConfig::max_hold`](crate::action::EgressConfig::max_hold),
505 /// which is 30 s.
506 ///
507 /// Pin this explicitly in any fixture that relies on a class *not*
508 /// delivering: under the default [`Expiry::Deliver`] a starved class
509 /// still delivers at `max_hold`, so an unnamed 30 s is a margin the
510 /// test inherited rather than chose.
511 pub max_hold: Option<Duration>,
512 /// What happens when the queue is full.
513 pub overflow: Overflow,
514 /// What happens when a queued object outlives `max_hold`.
515 pub on_expiry: Expiry,
516}
517
518impl Default for QueueConfig {
519 fn default() -> Self {
520 Self {
521 depth_bytes: 1024 * 1024,
522 depth_objects: 256,
523 max_hold: None,
524 overflow: Overflow::default(),
525 on_expiry: Expiry::default(),
526 }
527 }
528}
529
530/// What happens when a per-stream queue is full.
531///
532/// `#[non_exhaustive]`, with `Block` as the [`Default`]: the only
533/// non-destructive answer is the one a caller gets without asking.
534///
535/// There is deliberately no `DropHead`. Dropping an *already queued* unit
536/// happens after the framer's positional cursor has moved past it, so the
537/// elide fix-up can no longer be armed — and on drafts 14-19 object IDs are
538/// delta-encoded, so the result is not a gap but every successor decoding
539/// with a wrong absolute ID. [`Overflow::DropTail`] is sound for exactly
540/// the reason `DropHead` is not: it discards the *arriving* unit, at
541/// admission time, where the fix-up is still legal.
542///
543/// Written as `"block"`, `"drop-tail"` or `{"reset-stream": {"code": 1}}`.
544#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
545#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
546#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case", deny_unknown_fields))]
547#[non_exhaustive]
548pub enum Overflow {
549 /// Stop reading the source. Non-destructive. **Default.**
550 ///
551 /// Per stream, and it does not reliably stall the *peer*: the
552 /// transport's own receive window absorbs megabytes first.
553 #[default]
554 Block,
555 /// Discard the *arriving* unit. Renumbers via the framer's own elide
556 /// fix-up, so absolute object IDs stay correct on drafts 14-19.
557 ///
558 /// When an elide guard refuses the fix-up the unit is admitted anyway —
559 /// the queue overshoots by one — and the refusal is reported. A shaper
560 /// may not corrupt a stream to honour a depth limit.
561 DropTail,
562 /// Abandon the destination stream.
563 ResetStream {
564 /// The application error code to reset with.
565 code: u64,
566 },
567}
568
569/// What happens when a queued object outlives `max_hold`.
570///
571/// `#[non_exhaustive]`, with `Deliver` as the [`Default`] — which is
572/// exactly today's behaviour, so nothing changes for a session that does
573/// not ask for shaping.
574///
575/// There is deliberately **no** `Drop` variant. An expiry is decided at
576/// release time, long after the framer's positional cursor has advanced
577/// past the object, so the elide fix-up cannot be armed; on drafts 14-19
578/// that corrupts every successor's absolute ID. A variant that is
579/// constructible and always refused is worse than an absent one.
580///
581/// The cost of the pick, stated so nobody rediscovers it: *the relay gave up on
582/// stale media* is expressible only at *stream* granularity, and
583/// `objects_expired` is therefore zero by default, with a producer only on the
584/// [`Expiry::ResetStream`] arm.
585///
586/// Written as `"deliver"` or `{"reset-stream": {"code": 1}}`.
587#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
588#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
589#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case", deny_unknown_fields))]
590#[non_exhaustive]
591pub enum Expiry {
592 /// Clamp the deadline and deliver anyway. Today's behaviour, and the
593 /// reason a starved class still delivers at `max_hold`. **Default.**
594 #[default]
595 Deliver,
596 /// Give up on the stream: drain what is due, then reset.
597 ResetStream {
598 /// The application error code to reset with.
599 code: u64,
600 },
601}
602
603/// How classes competing for the same bucket are arbitrated.
604///
605/// `#[non_exhaustive]`, with `Fifo` as the [`Default`]. The discipline only
606/// decides *between* classes; within one destination stream the queue stays
607/// a single FIFO whatever this says, because object IDs are delta-encoded
608/// on the wire and reordering them corrupts the chain.
609///
610/// Written as `"fifo"`, `"strict-priority"` or `*weighted-round-robin*`.
611#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
612#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
613#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
614#[non_exhaustive]
615pub enum Discipline {
616 /// Whoever asked first. **Default.**
617 #[default]
618 Fifo,
619 /// Highest [`ClassRule::priority`] first, and only then the rest.
620 StrictPriority,
621 /// Share by [`ClassRule::weight`]. A zero weight is rejected by
622 /// [`ShapeProfile::try_new`] under this discipline.
623 WeightedRoundRobin,
624}
625
626/// Identifies one forwarded stream within a session.
627///
628/// A **session-local monotonic id**, minted from one counter per session at
629/// the moment the session accepts the stream, plus the side it arrived on.
630/// Unique for the session's lifetime and never reused.
631///
632/// Deliberately **not** a media key: `subgroup_id` is absent on eight
633/// drafts and `track_alias` on every fetch stream, so a media-keyed
634/// serialize would silently miss.
635///
636/// Deliberately **not** the transport stream id either. On the WebTransport
637/// arm `SendStream::stream_id()` is the constant `0`
638/// (`moqtap-client/src/transport/mod.rs:133-137`), and the proxy really
639/// does accept WebTransport clients — so a transport-keyed `StreamKey`
640/// collapses every WT stream onto one entry per side. A serialize would
641/// then attach a stream to an arbitrary sibling, or to itself, which is a
642/// self-deadlock that degrades to a `max_hold` stall, and every per-stream
643/// report becomes unattributable.
644///
645/// The transport id is still worth reporting where it means something, so
646/// it stays a separate field on the events that carry both.
647/// `Hash` is hand-written because [`ProxySide`] does not derive it and
648/// lives in a module this type may not edit. It hashes the side's
649/// discriminant, so it agrees with the derived [`PartialEq`] exactly:
650/// equal keys hash equal, which is the whole obligation.
651#[derive(Debug, Clone, Copy, PartialEq, Eq)]
652pub struct StreamKey {
653 /// The direction the stream was accepted on.
654 pub side: ProxySide,
655 /// Session-local monotonic id. Never reused within a session, and not
656 /// comparable across sessions.
657 pub id: u64,
658}
659
660impl std::hash::Hash for StreamKey {
661 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
662 std::mem::discriminant(&self.side).hash(state);
663 self.id.hash(state);
664 }
665}
666
667/// The written form of a [`ShapeProfile`].
668///
669/// # Why the mirror exists
670///
671/// [`ShapeProfile`]'s four fields are private, and that is the whole of its
672/// safety: [`ShapeProfile::try_new`] is the only way to build one, and it
673/// refuses seven configurations that would otherwise arm and do nothing —
674/// chief among them a class naming a bucket that is not in `buckets`, which
675/// reports shaping and shapes nothing, and a profile with no classes at all,
676/// which matches nothing there is to match.
677///
678/// A derived `Deserialize` on `ShapeProfile` would reach those private fields
679/// directly and bypass all seven. So `ShapeProfile` deserializes
680/// `#[serde(try_from = "ShapeProfileSpec")]` instead: serde builds *this*
681/// type, whose fields are public and whose only job is to be built, and the
682/// [`TryFrom`] impl runs `try_new`. Every deserialization path therefore
683/// validates, and no consumer has to remember to convert — which matters
684/// because the consumer is usually a field on some caller's own
685/// configuration type, reached by someone who never names this one at all.
686///
687/// The reverse direction, [`From<&ShapeProfile>`](ShapeProfileSpec), exists so
688/// a caller can take a profile apart, edit it and rebuild it through the same
689/// validation.
690///
691/// `#[non_exhaustive]` **with** a [`Default`], as the shaping configs are: the
692/// attribute makes struct-expression and functional-update syntax illegal
693/// outside this crate, so the `Default` is what leaves a construction path
694/// open — `let mut spec = ShapeProfileSpec::default();` and then per-field
695/// assignment.
696#[cfg(feature = "serde")]
697#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Default)]
698#[serde(deny_unknown_fields)]
699#[non_exhaustive]
700pub struct ShapeProfileSpec {
701 /// The token buckets, by name. A class names one of these.
702 pub buckets: Vec<BucketConfig>,
703 /// The class rules, in the order they are matched — which is also the
704 /// order the statistics report them in.
705 pub classes: Vec<ClassRule>,
706 /// The per-stream queue policy. Defaults to
707 /// [`QueueConfig::default`], which is a usable policy rather than a
708 /// placeholder.
709 #[serde(default)]
710 pub queue: QueueConfig,
711 /// How classes sharing a bucket are arbitrated. Defaults to
712 /// [`Discipline::Fifo`].
713 #[serde(default)]
714 pub discipline: Discipline,
715}
716
717#[cfg(feature = "serde")]
718impl TryFrom<ShapeProfileSpec> for ShapeProfile {
719 type Error = ShapeError;
720
721 fn try_from(spec: ShapeProfileSpec) -> Result<Self, Self::Error> {
722 ShapeProfile::try_new(spec.buckets, spec.classes, spec.queue, spec.discipline)
723 }
724}
725
726#[cfg(feature = "serde")]
727impl From<&ShapeProfile> for ShapeProfileSpec {
728 fn from(profile: &ShapeProfile) -> Self {
729 Self {
730 buckets: profile.buckets().to_vec(),
731 classes: profile.classes().to_vec(),
732 queue: profile.queue().clone(),
733 discipline: profile.discipline(),
734 }
735 }
736}
737#[cfg(test)]
738mod tests {
739 use super::*;
740
741 fn bucket(name: &str) -> BucketConfig {
742 BucketConfig {
743 name: name.to_string(),
744 rate_bps: Some(64_000),
745 burst_bytes: 16_000,
746 ..BucketConfig::default()
747 }
748 }
749
750 fn class(name: &str) -> ClassRule {
751 ClassRule {
752 name: name.to_string(),
753 bucket: "b".to_string(),
754 weight: 1,
755 ..ClassRule::default()
756 }
757 }
758
759 /// A profile that `try_new` accepts, so every rejection below is
760 /// attributable to the one field its row edits.
761 fn valid() -> (Vec<BucketConfig>, Vec<ClassRule>, QueueConfig, Discipline) {
762 (
763 vec![bucket("b")],
764 vec![class("audio"), class("video")],
765 QueueConfig::default(),
766 Discipline::Fifo,
767 )
768 }
769
770 #[test]
771 fn a_valid_profile_round_trips_through_its_accessors() {
772 let (buckets, classes, queue, discipline) = valid();
773 let p = ShapeProfile::try_new(buckets, classes, queue.clone(), discipline)
774 .expect("the control profile must be valid or every rejection below is unattributable");
775 assert_eq!(p.buckets().len(), 1);
776 assert_eq!(
777 p.classes().iter().map(|c| c.name.as_str()).collect::<Vec<_>>(),
778 ["audio", "video"]
779 );
780 assert_eq!(p.queue(), &queue);
781 assert_eq!(p.discipline(), Discipline::Fifo);
782 }
783
784 /// The named single case for the egress-side rejection.
785 ///
786 /// Kept beside the table below rather than folded into it: this is the
787 /// one rejection that is about the *proxy's* topology rather than about
788 /// the profile's internal consistency, and it is the rejection a
789 /// scenario author is most likely to trip.
790 #[test]
791 fn try_new_rejects_an_egress_side() {
792 for side in [ProxySide::ProxyToClient, ProxySide::ProxyToRelay] {
793 let (buckets, mut classes, queue, discipline) = valid();
794 classes[1].matcher.side = Some(side);
795 assert_eq!(
796 ShapeProfile::try_new(buckets, classes, queue, discipline),
797 Err(ShapeError::EgressSideInMatcher { class: "video".to_string() }),
798 "{side:?} is an egress label and no hook site ever sees it"
799 );
800 }
801
802 // The two ingress sides are accepted, so the rejection is about the
803 // direction and not about the field being set at all.
804 for side in [ProxySide::ClientToProxy, ProxySide::RelayToProxy] {
805 let (buckets, mut classes, queue, discipline) = valid();
806 classes[1].matcher.side = Some(side);
807 assert!(ShapeProfile::try_new(buckets, classes, queue, discipline).is_ok());
808 }
809 }
810
811 /// One row per `ShapeError` variant. Seven variants, seven rows, and
812 /// the count is asserted so an eighth variant cannot be added without a
813 /// row.
814 #[test]
815 fn try_new_rejects_every_invalid_profile() {
816 type Edit =
817 fn(&mut Vec<BucketConfig>, &mut Vec<ClassRule>, &mut QueueConfig, &mut Discipline);
818
819 let rows: [(&str, Edit, ShapeError); 7] = [
820 (
821 "two classes share a name",
822 |_b, c, _q, _d| c[1].name = "audio".to_string(),
823 ShapeError::DuplicateClassName { name: "audio".to_string() },
824 ),
825 (
826 "a class names a bucket that is not configured",
827 |_b, c, _q, _d| c[1].bucket = "nope".to_string(),
828 ShapeError::UnknownBucket {
829 class: "video".to_string(),
830 bucket: "nope".to_string(),
831 },
832 ),
833 (
834 "a matcher names an egress side",
835 |_b, c, _q, _d| c[1].matcher.side = Some(ProxySide::ProxyToClient),
836 ShapeError::EgressSideInMatcher { class: "video".to_string() },
837 ),
838 (
839 "weighted round robin with a zero weight",
840 |_b, c, _q, d| {
841 *d = Discipline::WeightedRoundRobin;
842 c[1].weight = 0;
843 },
844 ShapeError::ZeroWeight { class: "video".to_string() },
845 ),
846 (
847 "a queue that can hold nothing",
848 |_b, _c, q, _d| q.depth_objects = 0,
849 ShapeError::EmptyQueue,
850 ),
851 (
852 "a matcher key that names no value at all",
853 |_b, c, _q, _d| c[1].matcher.every_nth = Some((0, 0)),
854 ShapeError::InertMatcher { class: "video".to_string(), key: "every_nth" },
855 ),
856 (
857 "a profile with no class rules at all",
858 |_b, c, _q, _d| c.clear(),
859 ShapeError::NoClasses,
860 ),
861 ];
862
863 for (label, edit, want) in rows {
864 let (mut buckets, mut classes, mut queue, mut discipline) = valid();
865 edit(&mut buckets, &mut classes, &mut queue, &mut discipline);
866 assert_eq!(
867 ShapeProfile::try_new(buckets, classes, queue, discipline),
868 Err(want),
869 "{label}"
870 );
871 }
872
873 // The byte half of `EmptyQueue`, which the row above cannot also
874 // cover without testing two fields in one assertion.
875 let (buckets, classes, mut queue, discipline) = valid();
876 queue.depth_bytes = 0;
877 assert_eq!(
878 ShapeProfile::try_new(buckets, classes, queue, discipline),
879 Err(ShapeError::EmptyQueue)
880 );
881
882 // A zero weight is only an error under WeightedRoundRobin, so the
883 // fourth row is about the pairing and not about the weight.
884 let (buckets, mut classes, queue, discipline) = valid();
885 classes[1].weight = 0;
886 assert!(ShapeProfile::try_new(buckets, classes, queue, discipline).is_ok());
887 }
888
889 /// **`try_new` rejects every class that can never claim anything** —
890 /// one of the silent no-ops this constructor exists to make loud.
891 /// The constructor's own reason for existing is that *a mis-typed bucket
892 /// name would not be a silently inert class — configuration that looks
893 /// applied and does nothing*, and it checked five things, none of which was
894 /// the matcher's own arithmetic. All three rows below were accepted as
895 /// valid profiles and delivered zero bytes in silence; the detector
896 /// (`RangeSet::is_empty`) was already written and already public.
897 ///
898 /// Each row is paired with the **same key holding a real value**, in
899 /// the same body, so a rejection cannot be passing because `try_new`
900 /// rejects any profile that sets that key at all — which would be a far
901 /// worse defect than the one being fixed.
902 ///
903 /// *Ablation, recorded:* delete the `inert_key` check from `try_new` —
904 /// all three rejections redden here, the inert-matcher row of
905 /// `try_new_rejects_every_invalid_profile` reddens with it,
906 /// and each `left` is the accepted profile printed in full, which is the
907 /// point: the thing that ships is a `ShapeProfile` that looks entirely
908 /// ordinary and holds `track_alias: Some(RangeSet { ranges: [] })`.
909 ///
910 /// ```text
911 /// assertion `left == right` failed: an inverted track_alias range is an empty
912 /// set, so this class can never claim a unit
913 /// left: Ok(ShapeProfile { .. track_alias: Some(RangeSet { ranges: [] }) .. })
914 /// right: Err(InertMatcher { class: "video", key: "track_alias" })
915 /// ```
916 #[test]
917 fn try_new_rejects_a_class_that_can_never_claim_a_unit() {
918 // Bound through locals: a literal `9..=1` is a clippy error at the
919 // call site (`reversed_empty_ranges`), and a configuration built at
920 // runtime is exactly where these come from.
921 let (lo, hi) = (9u64, 1u64);
922 let (top, bottom) = (200u8, 100u8);
923
924 let build = |edit: &dyn Fn(&mut Matcher)| {
925 let (buckets, mut classes, queue, discipline) = valid();
926 edit(&mut classes[1].matcher);
927 ShapeProfile::try_new(buckets, classes, queue, discipline)
928 };
929 let inert =
930 |key: &'static str| Err(ShapeError::InertMatcher { class: "video".to_string(), key });
931
932 assert_eq!(
933 build(&|m| m.track_alias = Some(RangeSet::new([lo..=hi]))),
934 inert("track_alias"),
935 "an inverted track_alias range is an empty set, so this class can \
936 never claim a unit"
937 );
938 assert_eq!(
939 build(&|m| m.priority = Some(top..=bottom)),
940 inert("priority"),
941 "an empty priority range contains no value, so no header can satisfy it"
942 );
943 assert_eq!(
944 build(&|m| m.every_nth = Some((0, 0))),
945 inert("every_nth"),
946 "`n == 0` names no units, which `Matcher::matches` answers false for \
947 unconditionally"
948 );
949
950 // The positive controls: the same three keys holding real values
951 // are ordinary working rules.
952 assert!(build(&|m| m.track_alias = Some(RangeSet::new([hi..=lo]))).is_ok());
953 assert!(build(&|m| m.priority = Some(bottom..=top)).is_ok());
954 assert!(build(&|m| m.every_nth = Some((2, 0))).is_ok());
955
956 // And the empty range really is what `RangeSet` stores, so the
957 // rejection is about emptiness and not about the literal.
958 assert!(RangeSet::new([lo..=hi]).is_empty());
959 }
960
961 /// Duplicate *bucket* names are deliberately not an error, unlike
962 /// duplicate class names. Pinned so the asymmetry is a decision rather
963 /// than an oversight.
964 #[test]
965 fn duplicate_bucket_names_are_not_an_error() {
966 let (_, classes, queue, discipline) = valid();
967 assert!(ShapeProfile::try_new(vec![bucket("b"), bucket("b")], classes, queue, discipline)
968 .is_ok());
969 }
970
971 /// The defaults the `..Default::default()` idiom hands a caller are
972 /// usable as they stand — a derived `QueueConfig::default()` would be
973 /// `EmptyQueue` and every profile that did not restate both depths
974 /// would be rejected.
975 #[test]
976 fn the_default_queue_config_is_a_profile_that_builds() {
977 let (buckets, classes, _, discipline) = valid();
978 assert!(ShapeProfile::try_new(buckets, classes, QueueConfig::default(), discipline).is_ok());
979
980 let q = QueueConfig::default();
981 assert_ne!(q.depth_bytes, 0);
982 assert_ne!(q.depth_objects, 0);
983 assert_eq!(q.max_hold, None);
984 assert_eq!(q.overflow, Overflow::Block);
985 assert_eq!(q.on_expiry, Expiry::Deliver);
986 assert_eq!(Discipline::default(), Discipline::Fifo);
987 }
988
989 /// `StreamKey` is session-local and side-scoped: the same id on the two
990 /// sides is two keys, or a registry entry would be shared by the two
991 /// halves of a forwarded pair.
992 #[test]
993 fn a_stream_key_is_scoped_by_side_as_well_as_id() {
994 let a = StreamKey { side: ProxySide::ClientToProxy, id: 1 };
995 let b = StreamKey { side: ProxySide::RelayToProxy, id: 1 };
996 assert_ne!(a, b);
997 assert_eq!(a, StreamKey { side: ProxySide::ClientToProxy, id: 1 });
998
999 let mut set = HashSet::new();
1000 assert!(set.insert(a));
1001 assert!(set.insert(b));
1002 assert!(!set.insert(a));
1003 }
1004
1005 /// A profile with one bucket and one class, valid by construction.
1006 ///
1007 /// Built with functional-update syntax, which compiles here and would not
1008 /// outside this crate: `#[non_exhaustive]` makes both the struct literal
1009 /// and `..Default::default()` illegal there, so a caller assigns per field
1010 /// on a `::default()` binding instead.
1011 #[cfg(feature = "serde")]
1012 fn profile() -> ShapeProfile {
1013 let bucket = BucketConfig {
1014 name: "video".to_owned(),
1015 rate_bps: Some(500_000),
1016 burst_bytes: 65_536,
1017 ..Default::default()
1018 };
1019
1020 let class = ClassRule {
1021 name: "video".to_owned(),
1022 bucket: "video".to_owned(),
1023 matcher: Matcher {
1024 side: Some(crate::types::ProxySide::ClientToProxy),
1025 track_alias: Some(crate::shape::RangeSet::new([1..=3, 9..=9])),
1026 ..Default::default()
1027 },
1028 ..Default::default()
1029 };
1030
1031 ShapeProfile::try_new(
1032 vec![bucket],
1033 vec![class],
1034 QueueConfig::default(),
1035 Discipline::StrictPriority,
1036 )
1037 .expect("a profile with one class naming its own bucket is valid")
1038 }
1039
1040 /// A `ShapeProfile` serializes into the shape `ShapeProfileSpec` reads
1041 /// back, and the two derives are on different types — one on the private
1042 /// fields, one on the mirror — so nothing but this test holds their field
1043 /// names together. A rename on either side lands here as a parse failure.
1044 #[cfg(feature = "serde")]
1045 #[test]
1046 fn shape_profile_round_trips_through_json() {
1047 let before = profile();
1048 let json = serde_json::to_string(&before).expect("a profile serializes");
1049 let after: ShapeProfile = serde_json::from_str(&json).expect("and reads back");
1050 assert_eq!(before, after, "round trip through {json}");
1051 }
1052
1053 /// A `RangeSet` is written as a plain list of ranges and read back through
1054 /// `RangeSet::new`, which sorts and coalesces — so a file listing
1055 /// overlapping ranges in any order produces the same set as one listing
1056 /// them merged, and `contains` cannot be reading an unsorted vector.
1057 #[cfg(feature = "serde")]
1058 #[test]
1059 fn range_sets_coalesce_when_they_are_read() {
1060 let json = r#"[{"start":4,"end":6},{"start":1,"end":3}]"#;
1061 let set: crate::shape::RangeSet = serde_json::from_str(json).expect("ranges parse");
1062 assert_eq!(set.ranges(), &[1..=6], "adjacent ranges are one range after a read");
1063 }
1064}