Skip to main content

moqtap_proxy/shape/
mod.rs

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