Skip to main content

moqtap_proxy/shape/
scheduler.rs

1//! The session-scoped shaper: classification, admission and release.
2//!
3//! [`Scheduler`] is the one object a shaped session's forwarding tasks
4//! share. It owns the validated [`ShapeProfile`], the report-once state
5//! that keeps a run from repeating itself, and the token buckets, and it
6//! answers three questions per unit:
7//!
8//! 1. **Which class is this?** — [`Scheduler::classify`], the
9//!    [`Matcher`](super::Matcher)
10//!    over [`ObjectMeta`] plus the fall to [`Class::Default`], plus the
11//!    `ShapeRuleUnmatchable` report that keeps that fall from being silent.
12//! 2. **Does it fit?** — [`Scheduler::admit`], the per-stream queue depth
13//!    and the [`Overflow`] policy.
14//! 3. **May it go now?** — [`Scheduler::acquire`], the token bucket and the
15//!    [`Discipline`] that arbitrates between classes
16//!    sharing one.
17//!
18//! The first two are **pure functions of their arguments and the profile**:
19//! neither reads a clock, touches a queue or writes a counter. The third
20//! owns state — that is what a bucket *is* — but it still takes `now` as a
21//! parameter and reads no clock, which is why the arithmetic under it stays
22//! provable from a table. The statistics are the caller's to record, and the
23//! reports are handed to a callback, so everything below is testable with no
24//! QUIC, no runtime and no session.
25//!
26//! # Release is per class; the stream performs it
27//!
28//! [`Scheduler::acquire`] is the whole of the release decision, and it is
29//! reachable from exactly one place: `PendingQueue::pop_next_due`, which is
30//! `&mut self`, is the sole ordering authority, and is called exactly once
31//! per released unit. It is deliberately **not** reachable from
32//! `PendingQueue::head_release`, which is `&self`, fires once per `select!`
33//! iteration rather than once per unit, and is also called by the two
34//! teardown drains and by the control pipes.
35//!
36//! **The control pipes are never shaped.** `pipe_control_passthrough` and
37//! `pipe_control_mutating` install no [`Scheduler`] on their queues at all,
38//! so no control frame can reach a bucket even by accident. Pacing
39//! SUBSCRIBE and ANNOUNCE behind a video bucket would stall MoQT's steady
40//! state and make an idle control stream look like a dead session.
41//!
42//! # Why starvation is a gate and not a poll
43//!
44//! A class that a discipline refuses has no deadline to arm — nothing about
45//! *time* will make it eligible, only the blocking class draining will. So
46//! [`Acquire::Starved`] hands back a [`Gate`], created under the same lock
47//! that reads the demand it is waiting on, and the withdrawal of that demand
48//! releases it. Level-triggered, so a withdrawal that races the park is
49//! observed rather than lost; a fresh gate per park, so a release cannot be
50//! mistaken for the next one. Polling instead would either spin a starved
51//! stream hot or add a latency nobody configured.
52//!
53//! # Why admission is per stream and release is per class
54//!
55//! Queue depth, [`Overflow`] and the framer's elide fix-up all live where
56//! `note_elided` is legal: at admission, on the *arriving* unit, before the
57//! framer's positional cursor has moved past it. Dropping an
58//! already-queued unit at release time would not leave a gap in absolute
59//! object IDs on drafts 14-23 — it would leave every successor decoding a
60//! *wrong* ID. That single fact is why [`Overflow::DropTail`] exists and
61//! `DropHead` does not.
62
63use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
64use std::sync::{Arc, Mutex};
65use std::time::{Duration, Instant};
66
67use crate::action::Gate;
68use crate::types::{ObjectMeta, ProxySide};
69
70use moqtap_codec::dispatch::AnyDatagramMeta;
71
72use moqtap_codec::version::DraftVersion;
73
74use super::bucket::{charge, BucketState, Grant};
75use super::matcher::MatcherField;
76use super::{Discipline, Expiry, Overflow, ShapeProfile};
77
78/// Which row of [`ShapeStats`](super::ShapeStats) a unit is charged to.
79///
80/// `Copy`, and an index rather than a name: the storage is a pre-sized
81/// `Vec<ClassCounters>` in configured order, so charging a unit is one
82/// relaxed `fetch_add` at a known offset and never a map lookup.
83#[derive(Clone, Copy, Debug, PartialEq, Eq)]
84pub(crate) enum Class {
85    /// The rule at this index in [`ShapeProfile::classes`].
86    Rule(usize),
87    /// No rule claimed the unit.
88    ///
89    /// This is the falsifiable signal that a rule did not fire: an author
90    /// who expected a class to claim everything and reads
91    /// `ShapeStats::default_class.objects_delivered > 0` knows their rule
92    /// did not fire, and the `ShapeRuleUnmatchable` impairment says why.
93    ///
94    /// **`objects_delivered`, not `objects_dropped`** — `objects_dropped`
95    /// has a producer only under [`Overflow::DropTail`], so under the
96    /// default [`Overflow::Block`] it is permanently zero and an author
97    /// told to read it would read a zero and conclude their rule had fired.
98    /// `objects_delivered` has a producer under every overflow policy.
99    /// (The drop row is still the right one to read in a
100    /// `DropTail` fixture, which is why
101    /// `a_datagram_rule_says_so_instead_of_matching_nothing` asserts it.)
102    Default,
103    /// There was nothing to classify: a subgroup stream header, an oversized
104    /// object the framer could only pass through, a bypassed stream's bytes.
105    /// Such a unit still takes an **ordering slot** — it is bytes on a wire
106    /// that has other bytes queued in front of it — but it charges no bucket,
107    /// because no rule can name what no `ObjectMeta` describes. Distinct from
108    /// [`Self::Default`], which is a unit the rules *did* see and none of them
109    /// claimed: conflating them would let *my rule matched nothing* and *there
110    /// was nothing for a rule to match* report as one number.
111    Unshapeable,
112}
113
114/// What admission decided about one **arriving** unit.
115///
116/// There is no `Block` variant, and its absence is the design: `Block` is
117/// not a decision taken about a unit that arrived, it is the read branch
118/// never being polled, so no unit arrives to decide about. That is what
119/// makes [`Self::DropTail`] observable at all — see
120/// [`Scheduler::blocking_depth`].
121#[derive(Clone, Copy, Debug, PartialEq, Eq)]
122pub(crate) enum Admission {
123    /// Forward it, exactly as an unshaped session would.
124    Admit,
125    /// Discard it. The caller must run it through the framer's elide path
126    /// so absolute object IDs on drafts 14-23 stay correct, and must admit
127    /// it anyway if an elide guard refuses.
128    DropTail,
129    /// Abandon the destination stream with `code`.
130    ResetStream {
131        /// The application error code to reset with.
132        code: u64,
133    },
134}
135
136/// What release decided about one **queued** unit.
137///
138/// The four "not now" answers are four different waits, and collapsing them
139/// is how a shaper stalls or misreports: a bucket that will refill names an
140/// instant, a bucket that will never refill names nothing at all, a bucket
141/// too small to hold one unit names nothing *and* is a misconfiguration, and
142/// a discipline that is holding the class back names an event.
143#[derive(Clone, Debug)]
144pub(crate) enum Acquire {
145    /// Write it. The tokens have already been debited.
146    Now,
147    /// Not yet — the bucket holds enough at this instant and not before.
148    ///
149    /// *Earliest*, not exact: another stream sharing the bucket may drain it
150    /// again first, in which case the next [`Scheduler::acquire`] answers
151    /// `Later` again with a new instant.
152    Later(Instant),
153    /// Not yet, and no instant exists: the rate is `Some(0)`, so the bucket
154    /// never refills. The caller supplies its own deadline — the unit's
155    /// `max_hold` clamp — because arming a fabricated far-future instant
156    /// would arm a timer that never fires and nothing downstream could tell
157    /// it from a real deadline.
158    Never,
159    /// Not yet, and not ever: this class's `burst_bytes` is smaller than the
160    /// unit at the head of its queue, so its configured rate can never pace
161    /// anything and every unit leaves at the clamp instead.
162    ///
163    /// **Handled exactly as [`Self::Never`] is** — the clamp is the deadline
164    /// in both cases — and kept apart from it for one reason: this one is a
165    /// misconfiguration and `Never` is a configuration. Without the split
166    /// the two produce the same clamp, the same `tokens_exhausted_episodes`
167    /// and the same `HoldClamped`, so an author who wrote a rate and left
168    /// the burst too small reads a throughput unrelated to their number with
169    /// no signal that anything is wrong. The caller pairs this with
170    /// [`Scheduler::claim_burst_report`] and reports it once per class.
171    LargerThanBurst {
172        /// The class's bucket cap.
173        burst_bytes: u64,
174        /// The unit it could not cover.
175        unit_bytes: u64,
176    },
177    /// Not yet, and the reason is not the bucket: another class sharing it
178    /// is ahead under the configured [`Discipline`]. Wait on this gate; it
179    /// is released when the blocking demand withdraws.
180    ///
181    /// Charged to `starved_behind_other_class` and **never** to
182    /// `tokens_exhausted_episodes` — two causes, two counters.
183    Starved(Gate),
184}
185
186/// The per-stream queue depth, in both units at once.
187///
188/// Both limits, always: a queue of small objects reaches neither the byte
189/// depth nor `EgressConfig::max_pending_bytes`, and a queue of large ones
190/// reaches the byte depth long before the object count.
191#[derive(Clone, Copy, Debug, PartialEq, Eq)]
192pub(crate) struct QueueDepth {
193    /// Bytes one stream's queue may hold.
194    pub(crate) bytes: usize,
195    /// Objects one stream's queue may hold.
196    pub(crate) objects: usize,
197}
198
199/// One session's shaper.
200///
201/// Held behind an `Arc` on `ForwardCtx` and cloned into every forwarding
202/// task, exactly as the two recorders are — and, unlike them,
203/// **`None` when the session has no profile**. There is nothing for an
204/// unshaped session to share, and an `Option` on the context is what makes
205/// "a session with `shape: None` adds nothing to this path" a compile-time
206/// fact rather than a runtime hope.
207pub(crate) struct Scheduler {
208    profile: ShapeProfile,
209    /// One report-once mask per class, indexed the same way
210    /// [`Class::Rule`] is. Bit `MatcherField::bit` is set the first time
211    /// that `(class, field)` pair is reported.
212    /// Relaxed atomics rather than a `Mutex`: the only operation is *set this
213    /// bit, tell me whether it was already set*, the only consequence of losing
214    /// a race is one duplicate event on a path that fires at most `classes × 4`
215    /// times per session, and taking a lock per non-matching rule per unit
216    /// would put a contended mutex on the data path of every shaped session.
217    unmatchable: Vec<AtomicU8>,
218    /// One report-once flag per class for *this class's burst cannot cover one
219    /// of its own units*, indexed the same way [`Class::Rule`] is.
220    ///
221    /// A separate mask from [`Self::unmatchable`] rather than a fifth bit in
222    /// it, because the two are answers to different questions and share no
223    /// key: that one is per `(class, field)` and is set from classification,
224    /// this one is per class and is set from release. Sharing the storage
225    /// would make the *first* release refusal silence a later unmatchable
226    /// field report on the same class, which is a diagnostic losing to an
227    /// unrelated one.
228    /// A relaxed `swap` for the same reason the other mask is relaxed: the only
229    /// operation is *claim this, tell me whether it was already claimed*, the
230    /// only cost of losing the race is one duplicate event, and the path fires
231    /// at most once per class per session.
232    burst_reported: Vec<AtomicBool>,
233    /// Which bucket each class charges, resolved once at construction.
234    ///
235    /// `ShapeProfile::try_new` has already rejected a class naming a bucket
236    /// that is not configured, so this is a total map and the lookup on the
237    /// data path is an index rather than a string compare.
238    class_bucket: Vec<usize>,
239    /// Everything release mutates, behind one lock.
240    ///
241    /// A `Mutex` and not a set of atomics, and the reason is the gate: a
242    /// class parks on a gate created from the *same* read of the demand that
243    /// decided to park it, and the withdrawal that releases it happens under
244    /// the same lock. Split across atomics there is a window in which a
245    /// withdrawal wakes nobody and the park that follows waits for a wake
246    /// that has already happened. The lock is taken once per released unit,
247    /// not once per byte, on a path that is already doing a `write_all`.
248    state: Mutex<SchedState>,
249    /// Whether this scheduler paces at all.
250    ///
251    /// Shared with every other scheduler built from the same proxy, which is
252    /// what makes turning pacing off a single act rather than a walk over
253    /// the sessions. A scheduler built with no switch of its own owns one
254    /// that is set and never cleared, so pacing is never switched off under
255    /// a session driven without a proxy.
256    ///
257    /// Read in [`Self::acquire`] and nowhere else. Everything a shaper does
258    /// *besides* pacing — classification, the per-stream queue depth, the
259    /// statistics — keeps running while it is clear, so the profile is still
260    /// there to resume with and the rows a caller was reading keep moving.
261    enabled: Arc<AtomicBool>,
262}
263
264/// The scheduler's mutable half.
265struct SchedState {
266    /// One per [`ShapeProfile::buckets`] entry, in configured order.
267    buckets: Vec<BucketState>,
268    /// How many stream queues currently hold an unreleased head of this
269    /// class. The **demand** a discipline arbitrates over.
270    ///
271    /// Maintained by `PendingQueue`, which declares its head's class and
272    /// withdraws on every path that can retire one — including `clear`,
273    /// the teardown drain and `Drop`. A leaked declaration would starve a
274    /// lower class for the rest of the session, which is why the withdrawal
275    /// is a `Drop` obligation and not a list of call sites.
276    demand: Vec<u64>,
277    /// Releases remaining in this round, per class, under
278    /// [`Discipline::WeightedRoundRobin`]. Refilled to
279    /// [`ClassRule::weight`](super::ClassRule::weight) when every class with
280    /// demand in a bucket has spent its own.
281    credits: Vec<u32>,
282    /// The gate each starved class is parked on, or `None` when it is not
283    /// parked. Taken and released by whoever makes the class eligible.
284    parked: Vec<Option<Gate>>,
285}
286
287impl Scheduler {
288    /// Build the shaper for `profile`, with every bucket full as of `now`.
289    ///
290    /// Full rather than empty: an empty burst would put a
291    /// `burst_bytes / rate_bps` delay in front of the first object of every
292    /// session and read as a broken proxy.
293    pub(crate) fn new(profile: ShapeProfile) -> Self {
294        Self::at(profile, Instant::now())
295    }
296
297    /// [`Self::new`], sharing `enabled` with every other scheduler this proxy
298    /// builds.
299    ///
300    /// The switch is handed in rather than owned because turning pacing off
301    /// has to be one act for the whole proxy: a session builds its own
302    /// scheduler, and a per-scheduler flag would have to be found and
303    /// cleared once per session, which is a walk over a set that changes
304    /// while it is being walked.
305    pub(crate) fn with_switch(profile: ShapeProfile, enabled: Arc<AtomicBool>) -> Self {
306        Self::build(profile, Instant::now(), enabled)
307    }
308
309    /// [`Self::new`] with the clock supplied, so the unit tests below fabricate
310    /// their own timeline exactly as [`charge`] lets them.
311    pub(crate) fn at(profile: ShapeProfile, now: Instant) -> Self {
312        Self::build(profile, now, Arc::new(AtomicBool::new(true)))
313    }
314
315    /// The one constructor. Both public ones differ only in where the clock
316    /// and the switch come from.
317    fn build(profile: ShapeProfile, now: Instant, enabled: Arc<AtomicBool>) -> Self {
318        let unmatchable = profile.classes().iter().map(|_| AtomicU8::new(0)).collect();
319        let burst_reported = profile.classes().iter().map(|_| AtomicBool::new(false)).collect();
320        let class_bucket = profile
321            .classes()
322            .iter()
323            .map(|c| {
324                profile
325                    .buckets()
326                    .iter()
327                    .position(|b| b.name == c.bucket)
328                    // `try_new` rejects `UnknownBucket`, so this is
329                    // unreachable; charging bucket 0 rather than panicking on
330                    // a forwarding task is the same total-function discipline
331                    // `ShapeRecorder::row` takes.
332                    .unwrap_or(0)
333            })
334            .collect();
335        let state = SchedState {
336            buckets: profile
337                .buckets()
338                .iter()
339                .map(|b| BucketState::new(b.burst_bytes, now))
340                .collect(),
341            demand: vec![0; profile.classes().len()],
342            credits: profile.classes().iter().map(|c| u32::from(c.weight)).collect(),
343            parked: profile.classes().iter().map(|_| None).collect(),
344        };
345        Self {
346            profile,
347            unmatchable,
348            burst_reported,
349            class_bucket,
350            state: Mutex::new(state),
351            enabled,
352        }
353    }
354
355    /// The name to label a report with, for a class index.
356    pub(crate) fn class_name(&self, index: usize) -> String {
357        self.profile.classes()[index].name.clone()
358    }
359
360    /// The deadline a queued unit is clamped to, or `None` to inherit
361    /// [`EgressConfig::max_hold`](crate::action::EgressConfig::max_hold).
362    /// Under the default [`Expiry::Deliver`] this is the instant a starved unit
363    /// goes out **anyway**, which is why every starvation fixture is required
364    /// to pin it: *a 0-bps class delivers zero bytes* is a statement about a
365    /// sampling window, and the window only means something against a ceiling
366    /// the fixture wrote down.
367    pub(crate) fn max_hold(&self) -> Option<Duration> {
368        self.profile.queue().max_hold
369    }
370
371    /// What happens to a unit that outlives its clamp.
372    pub(crate) fn on_expiry(&self) -> Expiry {
373        self.profile.queue().on_expiry
374    }
375
376    /// The depth `PendingQueue::accepts_more` must carry, or `None` when
377    /// this profile does not stop reading.
378    ///
379    /// **`Some` only under [`Overflow::Block`]**. Under `DropTail` the
380    /// read branch has to stay enabled, or nothing ever arrives to be
381    /// dropped and `objects_dropped` could only ever be zero; under
382    /// `ResetStream` the arriving unit is what triggers the reset. So the
383    /// depth is installed on the queue for exactly one policy, and the
384    /// other two evaluate it per unit through [`Self::admit`].
385    ///
386    /// Installing it *in* `accepts_more` rather than beside it is what
387    /// gives `Impairment{EgressQueueFull}` a producer here at all: the
388    /// once-per-transition latch is computed inside `PendingQueue::push`
389    /// from `accepts_more()`, so a stream full by `depth_objects` but under
390    /// `EgressConfig::max_pending_bytes` would otherwise never trip it.
391    pub(crate) fn blocking_depth(&self) -> Option<QueueDepth> {
392        let queue = self.profile.queue();
393        match queue.overflow {
394            Overflow::Block => {
395                Some(QueueDepth { bytes: queue.depth_bytes, objects: queue.depth_objects })
396            }
397            Overflow::DropTail | Overflow::ResetStream { .. } => None,
398        }
399    }
400
401    /// Classify one unit, reporting each unmatchable `(class, field)` once
402    /// per session.
403    ///
404    /// Rules are tried in configured order and the **first** match wins, so
405    /// a profile's order is a priority order for classification. A unit no
406    /// rule claims is [`Class::Default`].
407    ///
408    /// `report` is called with the class index and the key that could not
409    /// be carried, at most once per pair for the session's whole life. It
410    /// is a callback rather than a return value because the common answer
411    /// is "nothing to report" and building a collection to say so would
412    /// allocate on the data path.
413    ///
414    /// `unit_index` is the per-**stream** count of hook-visible units,
415    /// which is what `Matcher::every_nth` is defined against — never
416    /// `ObjectMeta::index_in_stream`, which counts oversized objects the
417    /// hook never sees and would silently shift the pattern.
418    ///
419    /// # The diagnostic sweep does not stop at the winner
420    /// The *classification* short-circuits — first match wins, and the loop
421    /// below stops asking `matches` once it has an answer. The unmatchability
422    /// check does **not**, and the asymmetry is what stops a dead rule going
423    /// unreported: *which class is this unit* is about one unit, but *can this
424    /// rule ever fire* is a question about the profile, and a rule's answer to
425    /// it does not depend on whether some earlier rule happened to claim this
426    /// particular unit.
427    ///
428    /// Returning at the winner would make it depend on exactly that. A
429    /// catch-all placed first — which is the value `ClassRule::default()` hands
430    /// you, since `Matcher::default()` claims everything — would match every
431    /// unit, so no later rule would ever be examined, silencing every
432    /// diagnostic behind it for the session's whole life.
433    ///
434    /// The winner itself is skipped, and that is not the same thing: a rule
435    /// that *matched* cannot have been defeated by an absent key, so
436    /// reporting it would be a false positive on a working rule.
437    ///
438    /// The cost is one `Matcher::unmatchable_fields` per rule per unit —
439    /// three `is_some` tests over a fixed-size array, no allocation, and the
440    /// relaxed `fetch_or` is reached only when a field is genuinely absent.
441    /// A profile whose keys are all carried never touches an atomic.
442    pub(crate) fn classify(
443        &self,
444        side: ProxySide,
445        meta: &ObjectMeta,
446        unit_index: u64,
447        mut report: impl FnMut(usize, MatcherField),
448    ) -> Class {
449        let mut claimed: Option<Class> = None;
450        for (index, rule) in self.profile.classes().iter().enumerate() {
451            if claimed.is_none() && rule.matcher.matches(side, meta, unit_index) {
452                claimed = Some(Class::Rule(index));
453                continue;
454            }
455            for field in rule.matcher.unmatchable_fields(meta).into_iter().flatten() {
456                self.report_once(index, field, &mut report);
457            }
458        }
459        claimed.unwrap_or(Class::Default)
460    }
461
462    /// [`Self::classify`] for a datagram.
463    ///
464    /// The same two-part sweep — first match wins, every rule that did not
465    /// match is asked whether it *could* have — over
466    /// [`Matcher::matches_datagram`] and
467    /// [`Matcher::unmatchable_fields_datagram`] instead of their framed
468    /// siblings. The report-once mask is the one `classify` uses, so a
469    /// session carrying both never reports a `(class, field)` pair twice and
470    /// whichever carrier arrives first is the one that says it.
471    ///
472    /// `unit_index` counts hook-visible datagrams per forwarding direction;
473    /// see [`Matcher::matches_datagram`] for why that is the only scope a
474    /// datagram has.
475    ///
476    /// The draft is taken as an argument rather than read off the unit
477    /// because an [`AnyDatagramMeta`] does not carry one — it is the resolved
478    /// identity, not the header — where an [`ObjectMeta`] does.
479    pub(crate) fn classify_datagram(
480        &self,
481        side: ProxySide,
482        draft: DraftVersion,
483        meta: &AnyDatagramMeta,
484        unit_index: u64,
485        mut report: impl FnMut(usize, MatcherField),
486    ) -> Class {
487        let mut claimed: Option<Class> = None;
488        for (index, rule) in self.profile.classes().iter().enumerate() {
489            if claimed.is_none() && rule.matcher.matches_datagram(side, meta, unit_index) {
490                claimed = Some(Class::Rule(index));
491                continue;
492            }
493            for field in rule.matcher.unmatchable_fields_datagram(draft, meta).into_iter().flatten()
494            {
495                self.report_once(index, field, &mut report);
496            }
497        }
498        claimed.unwrap_or(Class::Default)
499    }
500
501    /// Set `(index, field)`'s bit and call `report` only if this is the
502    /// first time anyone has.
503    ///
504    /// One relaxed `fetch_or`; see [`Self::unmatchable`] for why the losing
505    /// side of a race costs one duplicate event rather than a lock.
506    fn report_once(
507        &self,
508        index: usize,
509        field: MatcherField,
510        report: &mut impl FnMut(usize, MatcherField),
511    ) {
512        let Some(mask) = self.unmatchable.get(index) else { return };
513        if mask.fetch_or(field.bit(), Ordering::Relaxed) & field.bit() == 0 {
514            report(index, field);
515        }
516    }
517
518    /// Whether a unit of `bytes` fits behind `queued_objects` units holding
519    /// `queued_bytes`, and what to do if it does not.
520    ///
521    /// The byte test counts the arriving unit (`queued_bytes + bytes >
522    /// depth_bytes`) and the object test does not (`queued_objects >=
523    /// depth_objects`), because they answer different questions: the byte
524    /// depth is a budget the queue may not exceed, and the object depth is
525    /// a count of slots, all of which are taken.
526    ///
527    /// [`Overflow::Block`] answers [`Admission::Admit`] here and always
528    /// will: under `Block` a full queue is one the read branch is not
529    /// polling, so a unit reaching this function proves there was room for
530    /// it. Deciding to drop it here as well would double-count the same
531    /// limit and silently make `Block` destructive.
532    pub(crate) fn admit(
533        &self,
534        bytes: usize,
535        queued_bytes: usize,
536        queued_objects: usize,
537    ) -> Admission {
538        let queue = self.profile.queue();
539        let fits = queued_objects < queue.depth_objects
540            && queued_bytes.saturating_add(bytes) <= queue.depth_bytes;
541        if fits {
542            return Admission::Admit;
543        }
544        match queue.overflow {
545            Overflow::Block => Admission::Admit,
546            Overflow::DropTail => Admission::DropTail,
547            Overflow::ResetStream { code } => Admission::ResetStream { code },
548        }
549    }
550
551    // ── release ─────────────────────────────────────────────────────
552
553    /// Ask whether a queued unit of `class` holding `bytes` may go at `now`,
554    /// debiting its bucket when the answer is yes.
555    ///
556    /// **The release seam.** Called from `PendingQueue::pop_next_due` and
557    /// from nowhere else, exactly once per released unit.
558    /// [`Class::Default`] and [`Class::Unshapeable`] answer [`Acquire::Now`]
559    /// unconditionally and touch no lock: neither names a bucket, so there is
560    /// nothing to charge and nothing to arbitrate. That is not a shortcut — it
561    /// is what *no rule claimed this* means. A profile that wants a catch-all
562    /// charges one by writing a class with an all-`None`
563    /// [`Matcher`](super::Matcher).
564    ///
565    /// The discipline is consulted **before** the bucket, and the order is
566    /// load-bearing: charging first would let a class the discipline is
567    /// holding back spend tokens the class ahead of it is entitled to, and
568    /// the debit is not refundable.
569    ///
570    /// # While pacing is switched off
571    ///
572    /// Every unit is granted here, before the bucket is read and before the
573    /// discipline is consulted, so nothing is debited and no class is parked
574    /// while the switch is clear.
575    ///
576    /// **It is read when a queue asks, and a queue asks when its head's park
577    /// expires.** This function is the whole of the release decision, and a
578    /// queue that has been refused here parks its head on the deadline the
579    /// refusal named. Switching pacing off does not reach into that park; it
580    /// changes the answer the *next* call gives. So the delay between the
581    /// switch and a stream resuming is the park that was already running,
582    /// and which park that is depends on why the head was refused:
583    ///
584    /// * refused by a bucket that will refill — the refill instant, which is
585    ///   one unit's worth of the configured rate;
586    /// * refused by a bucket that never refills, or one whose burst cannot
587    ///   cover a unit — no instant exists, so the park is the queue's
588    ///   `max_hold` clamp, which on a stopped class is the whole of it;
589    /// * held back by the discipline — the class ahead withdrawing its
590    ///   demand, which is now immediate, because that class is granted here
591    ///   too.
592    ///
593    /// The middle case is the one to know: switching pacing off does **not**
594    /// promptly release a class configured at zero. Nothing here can, and
595    /// nothing else in this crate can either — the park is a timer a queue
596    /// armed, the queues are per stream, and no session-wide wake reaches
597    /// them.
598    ///
599    /// Nothing leaves a bucket half-charged whichever way the switch moves:
600    /// the debit happens on the same call as the grant or not at all.
601    pub(crate) fn acquire(&self, class: Class, bytes: u64, now: Instant) -> Acquire {
602        if !self.enabled.load(Ordering::Relaxed) {
603            return Acquire::Now;
604        }
605        let Class::Rule(index) = class else {
606            return Acquire::Now;
607        };
608        let Some(&bucket) = self.class_bucket.get(index) else {
609            return Acquire::Now;
610        };
611        let mut state = self.state.lock().expect("shape scheduler");
612
613        if let Some(gate) = self.discipline_holds(&mut state, index, bucket) {
614            return Acquire::Starved(gate);
615        }
616
617        let config = &self.profile.buckets()[bucket];
618        match charge(&mut state.buckets[bucket], config.rate_bps, config.burst_bytes, bytes, now) {
619            Grant::Now => {
620                if self.profile.discipline() == Discipline::WeightedRoundRobin {
621                    let credit = &mut state.credits[index];
622                    *credit = credit.saturating_sub(1);
623                }
624                Acquire::Now
625            }
626            Grant::Later(at) => Acquire::Later(at),
627            Grant::Never => Acquire::Never,
628            Grant::LargerThanBurst { burst_bytes, unit_bytes } => {
629                Acquire::LargerThanBurst { burst_bytes, unit_bytes }
630            }
631        }
632    }
633
634    /// Claim the once-per-session right to report that `class`'s burst is
635    /// smaller than one of its own units, and say what to call the class.
636    ///
637    /// `Some(name)` for the first caller and `None` for every one after, so
638    /// a class whose every unit is refused for the whole session reports
639    /// once rather than once per release attempt. The name comes back with
640    /// the claim because the caller needs both and asking twice would let a
641    /// caller claim one class and label another.
642    ///
643    /// **Once per session per class, not once per stream.** A burst that
644    /// cannot cover an object is a property of the profile, so every stream
645    /// carrying that class reproduces it and a per-stream report would say
646    /// the same thing as many times as the session has streams. The rows
647    /// that keep counting are the class's own `tokens_exhausted_episodes`
648    /// and the `HoldClamped` report on each clamped unit.
649    ///
650    /// Answers `None` for the two rows that name no bucket: neither
651    /// [`Class::Default`] nor [`Class::Unshapeable`] reaches [`charge`] at
652    /// all, so neither can have produced the refusal this reports.
653    pub(crate) fn claim_burst_report(&self, class: Class) -> Option<String> {
654        let Class::Rule(index) = class else { return None };
655        let claimed = self.burst_reported.get(index)?.swap(true, Ordering::Relaxed);
656        if claimed {
657            return None;
658        }
659        Some(self.class_name(index))
660    }
661
662    /// Whether the discipline is holding `index` back, and the gate to wait
663    /// on if it is.
664    ///
665    /// Scoped to the classes that **share `bucket`**: a discipline arbitrates
666    /// between classes competing for one bucket, and two classes with their
667    /// own buckets are not competing for anything.
668    fn discipline_holds(
669        &self,
670        state: &mut SchedState,
671        index: usize,
672        bucket: usize,
673    ) -> Option<Gate> {
674        let discipline = self.profile.discipline();
675        // Whoever asked first: the bucket alone decides, and it decides by
676        // arriving at `charge` first. Answered before anything is collected,
677        // so the default discipline allocates nothing per unit.
678        if discipline == Discipline::Fifo {
679            return None;
680        }
681
682        let classes = self.profile.classes();
683        // The classes this one is actually competing with: same bucket, not
684        // itself, and holding something to send.
685        let rivals: Vec<usize> = (0..classes.len())
686            .filter(|&j| j != index && self.class_bucket[j] == bucket && state.demand[j] > 0)
687            .collect();
688
689        match discipline {
690            Discipline::Fifo => return None,
691            Discipline::StrictPriority => {
692                let mine = classes[index].priority;
693                if !rivals.iter().any(|&j| classes[j].priority > mine) {
694                    return None;
695                }
696            }
697            Discipline::WeightedRoundRobin => {
698                if state.credits[index] > 0 {
699                    return None;
700                }
701                if rivals.iter().any(|&j| state.credits[j] > 0) {
702                    // Somebody else still owes this round: wait for them.
703                    // Deliberately *not* a refill — refilling here would let
704                    // a class that spent its share take a second one while a
705                    // rival still had credit, and the ratio would collapse to
706                    // whoever polls most often.
707                } else {
708                    // Every class with demand has spent its share, so the
709                    // round is over. Refill and wake the ones parked on it.
710                    for (j, rule) in classes.iter().enumerate() {
711                        if self.class_bucket[j] == bucket {
712                            state.credits[j] = u32::from(rule.weight);
713                        }
714                    }
715                    self.wake_bucket(state, bucket);
716                    return None;
717                }
718            }
719        }
720
721        // Parked. One gate per park, created under the lock that just read
722        // the demand it waits on, so a withdrawal cannot slip between the
723        // decision and the wait.
724        Some(state.parked[index].get_or_insert_with(Gate::new).clone())
725    }
726
727    /// Release every class parked on `bucket`.
728    ///
729    /// Over-waking is safe and deliberate: a woken class re-asks and parks
730    /// again on a *fresh* gate if it is still held back, which costs one
731    /// loop iteration. Under-waking is a stall until `max_hold`, so the
732    /// asymmetry decides the direction to err in.
733    fn wake_bucket(&self, state: &mut SchedState, bucket: usize) {
734        for j in 0..state.parked.len() {
735            if self.class_bucket[j] == bucket {
736                if let Some(gate) = state.parked[j].take() {
737                    gate.release();
738                }
739            }
740        }
741    }
742
743    /// Record that one stream queue now holds an unreleased head of `class`.
744    ///
745    /// Demand, not depth: one per *queue*, whatever it holds behind that
746    /// head. A discipline arbitrates between classes that have something to
747    /// send, and a class with ten streams waiting is not ten times more
748    /// entitled than a class with one.
749    pub(crate) fn declare_demand(&self, class: Class) {
750        let Class::Rule(index) = class else { return };
751        let mut state = self.state.lock().expect("shape scheduler");
752        if let Some(slot) = state.demand.get_mut(index) {
753            *slot += 1;
754        }
755    }
756
757    /// Undo one [`Self::declare_demand`], waking whatever it was holding back.
758    /// Called from every path that can retire a head — a grant, a `clear`, the
759    /// teardown drain, and `PendingQueue`'s `Drop`. The wake is unconditional
760    /// rather than *only when the count reached zero*, because the classes it
761    /// wakes re-ask and park again for free, while a missed wake is a
762    /// `max_hold` stall on a stream that has nothing wrong with it.
763    pub(crate) fn withdraw_demand(&self, class: Class) {
764        let Class::Rule(index) = class else { return };
765        let Some(&bucket) = self.class_bucket.get(index) else { return };
766        let mut state = self.state.lock().expect("shape scheduler");
767        if let Some(slot) = state.demand.get_mut(index) {
768            *slot = slot.saturating_sub(1);
769        }
770        self.wake_bucket(&mut state, bucket);
771    }
772}
773
774impl std::fmt::Debug for Scheduler {
775    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
776        f.debug_struct("Scheduler").field("profile", &self.profile).finish_non_exhaustive()
777    }
778}
779
780#[cfg(test)]
781mod tests {
782    use super::*;
783    use crate::shape::{
784        BucketConfig, ClassRule, Discipline, MatchKind, Matcher, QueueConfig, RangeSet,
785    };
786    use crate::types::DataStreamType;
787    use moqtap_codec::version::DraftVersion;
788    use std::cell::RefCell;
789
790    fn meta() -> ObjectMeta {
791        ObjectMeta {
792            draft: DraftVersion::Draft19,
793            stream_kind: DataStreamType::Subgroup,
794            track_alias: Some(7),
795            group_id: 3,
796            subgroup_id: Some(4),
797            object_id: 11,
798            publisher_priority: Some(128),
799            index_in_stream: 0,
800            payload_len: 16,
801            status: None,
802            end_of_range: None,
803        }
804    }
805
806    /// A datagram carrying every key one can carry, so a test can knock a
807    /// single one out and attribute the result.
808    fn datagram_meta() -> AnyDatagramMeta {
809        AnyDatagramMeta {
810            track_alias: 7,
811            group_id: 3,
812            object_id: 11,
813            publisher_priority: Some(128),
814            status: None,
815        }
816    }
817
818    fn class(name: &str, matcher: Matcher) -> ClassRule {
819        ClassRule {
820            name: name.to_string(),
821            bucket: "b".to_string(),
822            matcher,
823            weight: 1,
824            ..ClassRule::default()
825        }
826    }
827
828    fn scheduler(classes: Vec<ClassRule>, queue: QueueConfig) -> Scheduler {
829        let bucket = BucketConfig { name: "b".to_string(), ..BucketConfig::default() };
830        Scheduler::new(
831            ShapeProfile::try_new(vec![bucket], classes, queue, Discipline::Fifo)
832                .expect("the fixture names its own bucket"),
833        )
834    }
835
836    /// Collect every `(class index, field)` a classification reported.
837    fn reports<'a>(
838        s: &'a Scheduler,
839        meta: &'a ObjectMeta,
840    ) -> impl FnMut(u64) -> Vec<(usize, MatcherField)> + 'a {
841        move |unit_index| {
842            let seen = RefCell::new(Vec::new());
843            s.classify(ProxySide::ClientToProxy, meta, unit_index, |i, f| {
844                seen.borrow_mut().push((i, f));
845            });
846            seen.into_inner()
847        }
848    }
849
850    /// The first rule that claims a unit wins, and a unit no rule claims
851    /// falls to the default row rather than to the first rule.
852    #[test]
853    fn the_first_matching_rule_wins_and_the_rest_fall_to_default() {
854        let s = scheduler(
855            vec![
856                class(
857                    "high",
858                    Matcher { object_id: Some(RangeSet::new([0..=5])), ..Matcher::default() },
859                ),
860                class(
861                    "mid",
862                    Matcher { object_id: Some(RangeSet::new([0..=20])), ..Matcher::default() },
863                ),
864            ],
865            QueueConfig::default(),
866        );
867
868        let low = ObjectMeta { object_id: 3, ..meta() };
869        assert_eq!(
870            s.classify(ProxySide::ClientToProxy, &low, 0, |_, _| {}),
871            Class::Rule(0),
872            "both rules claim object 3; configured order decides"
873        );
874        assert_eq!(s.classify(ProxySide::ClientToProxy, &meta(), 0, |_, _| {}), Class::Rule(1));
875        let out = ObjectMeta { object_id: 99, ..meta() };
876        assert_eq!(s.classify(ProxySide::ClientToProxy, &out, 0, |_, _| {}), Class::Default);
877    }
878
879    /// A rule keyed on a field the wire did not carry falls to default
880    /// **and** reports, once per session per `(class, field)`.
881    ///
882    /// The three separate assertions are the three cases that must not be
883    /// conflated: absent-and-keyed reports, present-but-wrong does not, and
884    /// a second unit of the same shape does not report again.
885    #[test]
886    fn an_absent_key_reports_once_per_class_and_field() {
887        let s = scheduler(
888            vec![class(
889                "video",
890                Matcher { subgroup_id: Some(RangeSet::single(4)), ..Matcher::default() },
891            )],
892            QueueConfig::default(),
893        );
894
895        let absent = ObjectMeta { subgroup_id: None, ..meta() };
896        let mut classify = reports(&s, &absent);
897        assert_eq!(
898            classify(0),
899            vec![(0, MatcherField::SubgroupId)],
900            "the wire carried no subgroup ID, so this rule can never fire: say so"
901        );
902        assert_eq!(classify(1), vec![], "once per session per (class, field), not per unit");
903    }
904
905    /// A key that is *present but out of range* is a rule working, not a
906    /// rule that cannot work — so nothing is reported.
907    ///
908    /// Without this the report would fire on every ordinary non-match and
909    /// mean nothing.
910    #[test]
911    fn a_present_but_unmatched_key_reports_nothing() {
912        let s = scheduler(
913            vec![class(
914                "video",
915                Matcher { subgroup_id: Some(RangeSet::single(4)), ..Matcher::default() },
916            )],
917            QueueConfig::default(),
918        );
919        let other = ObjectMeta { subgroup_id: Some(9), ..meta() };
920        assert_eq!(reports(&s, &other)(0), vec![]);
921    }
922
923    /// A rule aimed at datagrams claims a datagram, walks past a framed
924    /// object, and reports nothing about either.
925    ///
926    /// Three assertions because the middle one is what the first would
927    /// otherwise be satisfied by: a `stream_kind` key that had stopped being
928    /// read would claim both, and a rule that claimed neither would fall to
929    /// default on both. The third is what a live `Datagram` rule turns the
930    /// silence into: not a rule that can never fire, but one that is live
931    /// and simply did not claim *this* unit.
932    #[test]
933    fn a_datagram_rule_claims_a_datagram() {
934        let s = scheduler(
935            vec![class(
936                "dgram",
937                Matcher { stream_kind: Some(MatchKind::Datagram), ..Matcher::default() },
938            )],
939            QueueConfig::default(),
940        );
941        let dgram = datagram_meta();
942        assert_eq!(
943            s.classify_datagram(
944                ProxySide::ClientToProxy,
945                DraftVersion::Draft19,
946                &dgram,
947                0,
948                |_, _| {}
949            ),
950            Class::Rule(0),
951            "a class aimed at datagrams must claim one"
952        );
953
954        let m = meta();
955        assert_eq!(
956            s.classify(ProxySide::ClientToProxy, &m, 0, |_, _| {}),
957            Class::Default,
958            "...and must not claim a framed object, or `stream_kind` is not a key"
959        );
960
961        // A **fresh** scheduler for the report, and this is load-bearing:
962        // the report-once mask is set by whichever call reaches it first,
963        // including one whose reporter discards what it is handed. Asking
964        // the scheduler above would read a mask the assertion before it had
965        // already consumed, and the assertion would pass for every possible
966        // implementation.
967        let fresh = scheduler(
968            vec![class(
969                "dgram",
970                Matcher { stream_kind: Some(MatchKind::Datagram), ..Matcher::default() },
971            )],
972            QueueConfig::default(),
973        );
974        assert_eq!(
975            reports(&fresh, &m)(0),
976            vec![],
977            "a Datagram rule is live now, so a framed object walking past it is \
978             an ordinary non-match and not a diagnostic"
979        );
980    }
981
982    /// **A rule behind the winner is still checked**, so a rule that can
983    /// never fire is not silenced by whichever rule happened to win.
984    ///
985    /// `classify` returned at the first match, so every rule after it —
986    /// including its unmatchability check — was skipped. A catch-all placed
987    /// first is the value `ClassRule::default()` hands you, and it silenced
988    /// every diagnostic behind it for the session's whole life.
989    ///
990    /// The two legs are the **same two rules in the two orders**, which is
991    /// what makes this attributable to the short-circuit and to nothing
992    /// else: same profile, same unit, same report-once state, one
993    /// difference. The classification is asserted on both legs too, because
994    /// a "fix" that reported the shadowed rule by also *letting it win*
995    /// would break first-match-wins, and the second assertion is what
996    /// forbids it.
997    #[test]
998    fn a_rule_shadowed_by_a_catch_all_is_still_checked() {
999        let catch_all = || class("everything", Matcher::default());
1000        // A rule keyed on the Track Alias, against a fetch unit, which
1001        // carries a Request ID where a subgroup unit carries an alias — so
1002        // the key is one the unit could not have had rather than one whose
1003        // value was wrong.
1004        let by_alias = || {
1005            class(
1006                "aliased",
1007                Matcher { track_alias: Some(RangeSet::single(7)), ..Matcher::default() },
1008            )
1009        };
1010        let m = ObjectMeta { stream_kind: DataStreamType::Fetch, track_alias: None, ..meta() };
1011
1012        let shadowed = scheduler(vec![catch_all(), by_alias()], QueueConfig::default());
1013        assert_eq!(
1014            reports(&shadowed, &m)(0),
1015            vec![(1, MatcherField::TrackAlias)],
1016            "a rule behind the winner can still be one that can never fire, and \
1017             the winner claiming this unit is not a statement about it"
1018        );
1019        assert_eq!(
1020            shadowed.classify(ProxySide::ClientToProxy, &m, 0, |_, _| {}),
1021            Class::Rule(0),
1022            "...and reporting it must not promote it: the first match still wins"
1023        );
1024
1025        // The control leg: the same two rules in the other order, which
1026        // must also report, and exactly once.
1027        let ordered = scheduler(vec![by_alias(), catch_all()], QueueConfig::default());
1028        assert_eq!(reports(&ordered, &m)(0), vec![(0, MatcherField::TrackAlias)]);
1029        assert_eq!(reports(&ordered, &m)(1), vec![], "still once per session per pair");
1030    }
1031
1032    /// The winner is **not** reported, however unmatchable its keys look.
1033    ///
1034    /// The guard against sweeping every rule unconditionally instead,
1035    /// which would fire `ShapeRuleUnmatchable` on the very class that is
1036    /// doing the work and make the report noise.
1037    ///
1038    /// Nearly every key makes this vacuous — an absent key never matches,
1039    /// so a rule keyed on one cannot be the winner. `stream_kind` is the
1040    /// exception and therefore the fixture: `kind_matches(Fetch, Fetch)` is
1041    /// `true` while `Fetch::is_matchable_on(Draft19)` is `false`, so a
1042    /// draft-19 fetch `ObjectMeta` is a unit that a Fetch rule both claims
1043    /// and is "unmatchable" for. The framer never builds one — drafts 15-19
1044    /// have no fetch object codec, so every fetch stream is bypassed at its
1045    /// header — which is exactly why it has to be built here: it is the only
1046    /// input that can tell the `continue` from a `return`.
1047    #[test]
1048    fn the_winning_rule_is_never_reported_as_unmatchable() {
1049        let s = scheduler(
1050            vec![class(
1051                "fetch",
1052                Matcher { stream_kind: Some(MatchKind::Fetch), ..Matcher::default() },
1053            )],
1054            QueueConfig::default(),
1055        );
1056        let claimed = ObjectMeta {
1057            draft: DraftVersion::Draft19,
1058            stream_kind: DataStreamType::Fetch,
1059            ..meta()
1060        };
1061        // The reporting assertion goes **first**, and deliberately: the mask
1062        // is once-per-session, so a classification run with a no-op reporter
1063        // would spend the budget before the reporting assertion runs.
1064        assert_eq!(
1065            reports(&s, &claimed)(0),
1066            vec![],
1067            "a rule that claimed the unit is a rule that fired; reporting it as \
1068             unable to fire would be a false positive on a working class"
1069        );
1070        assert_eq!(
1071            s.classify(ProxySide::ClientToProxy, &claimed, 0, |_, _| {}),
1072            Class::Rule(0),
1073            "the fixture is only meaningful if the rule really does win"
1074        );
1075
1076        // The contrast in the same body, so the assertion above is not
1077        // passing because this scheduler never reports: a rule against a
1078        // unit it does *not* claim, keyed on something that unit could not
1079        // have carried, reports at once. The key rather than the kind,
1080        // because naming a kind is not something a rule can be dead for —
1081        // see `Matcher::unmatchable_fields`.
1082        let s = scheduler(
1083            vec![class(
1084                "aliased",
1085                Matcher { track_alias: Some(RangeSet::single(7)), ..Matcher::default() },
1086            )],
1087            QueueConfig::default(),
1088        );
1089        let unaliased = ObjectMeta { track_alias: None, ..claimed };
1090        assert_eq!(reports(&s, &unaliased)(0), vec![(0, MatcherField::TrackAlias)]);
1091    }
1092
1093    /// The depth reaches `PendingQueue` under `Block` and nowhere else.
1094    #[test]
1095    fn only_block_installs_a_blocking_depth() {
1096        let depths = QueueConfig { depth_objects: 4, depth_bytes: 99, ..QueueConfig::default() };
1097        let with = |overflow| QueueConfig { overflow, ..depths.clone() };
1098
1099        let blocking = scheduler(vec![class("c", Matcher::default())], with(Overflow::Block));
1100        assert_eq!(blocking.blocking_depth(), Some(QueueDepth { bytes: 99, objects: 4 }));
1101
1102        for overflow in [Overflow::DropTail, Overflow::ResetStream { code: 7 }] {
1103            assert_eq!(
1104                scheduler(vec![class("c", Matcher::default())], with(overflow)).blocking_depth(),
1105                None,
1106                "{overflow:?} must leave the read branch enabled"
1107            );
1108        }
1109    }
1110
1111    /// Both depth limits, and the policy each one selects.
1112    #[test]
1113    fn admission_applies_both_depths_and_then_the_policy() {
1114        let depths = QueueConfig { depth_objects: 4, depth_bytes: 100, ..QueueConfig::default() };
1115        let with = |overflow| QueueConfig { overflow, ..depths.clone() };
1116        let s = scheduler(vec![class("c", Matcher::default())], with(Overflow::DropTail));
1117
1118        assert_eq!(s.admit(10, 0, 0), Admission::Admit, "an empty queue takes anything that fits");
1119        assert_eq!(s.admit(10, 90, 3), Admission::Admit, "exactly at the byte depth still fits");
1120        assert_eq!(s.admit(11, 90, 3), Admission::DropTail, "one byte over the byte depth");
1121        assert_eq!(s.admit(1, 0, 4), Admission::DropTail, "all four object slots are taken");
1122        assert_eq!(s.admit(1, 0, 3), Admission::Admit, "the fourth slot is free");
1123
1124        let s = scheduler(
1125            vec![class("c", Matcher::default())],
1126            with(Overflow::ResetStream { code: 0x2A }),
1127        );
1128        assert_eq!(s.admit(1, 0, 4), Admission::ResetStream { code: 0x2A });
1129
1130        // `Block` never decides here: a unit that reached admission under
1131        // `Block` proves the queue had room, because the read branch is
1132        // what enforces the limit.
1133        let s = scheduler(vec![class("c", Matcher::default())], with(Overflow::Block));
1134        assert_eq!(s.admit(1, 0, 4), Admission::Admit);
1135    }
1136
1137    // ── release ─────────────────────────────────────────────────────
1138
1139    /// A shaper over one bucket shared by every class, with the discipline
1140    /// and the per-class `(priority, weight)` given.
1141    fn shared_bucket(
1142        rate: Option<u64>,
1143        burst: u64,
1144        discipline: Discipline,
1145        classes: &[(&str, u8, u16)],
1146        now: Instant,
1147    ) -> Scheduler {
1148        let bucket = BucketConfig {
1149            name: "b".to_string(),
1150            rate_bps: rate,
1151            burst_bytes: burst,
1152            ..BucketConfig::default()
1153        };
1154        let rules = classes
1155            .iter()
1156            .map(|(name, priority, weight)| ClassRule {
1157                name: (*name).to_string(),
1158                bucket: "b".to_string(),
1159                matcher: Matcher::default(),
1160                priority: *priority,
1161                weight: *weight,
1162            })
1163            .collect();
1164        Scheduler::at(
1165            ShapeProfile::try_new(vec![bucket], rules, QueueConfig::default(), discipline)
1166                .expect("the fixture names its own bucket"),
1167            now,
1168        )
1169    }
1170
1171    fn granted(a: &Acquire) -> bool {
1172        matches!(a, Acquire::Now)
1173    }
1174
1175    /// Neither of the two rows that name no bucket can be charged one, and
1176    /// neither is arbitrated: an unclassified unit is not a class competing
1177    /// for anything.
1178    #[test]
1179    fn the_unclassified_rows_are_never_charged_a_bucket() {
1180        let base = Instant::now();
1181        // A bucket that grants nothing at all, so a charge is unmissable.
1182        let s = shared_bucket(Some(0), 0, Discipline::Fifo, &[("only", 0, 1)], base);
1183        for class in [Class::Default, Class::Unshapeable] {
1184            assert!(
1185                granted(&s.acquire(class, 4096, base)),
1186                "{class:?} names no bucket, so there is nothing to charge it against"
1187            );
1188        }
1189        // ...and the class that *does* name the bucket is refused, so the
1190        // assertion above is not passing because the bucket grants freely.
1191        assert!(matches!(s.acquire(Class::Rule(0), 1, base), Acquire::Never));
1192    }
1193
1194    /// The bound `charge` proves, reached through `acquire`: a rate-limited
1195    /// class is granted, then refused with the instant its own refill lands.
1196    #[test]
1197    fn a_rate_limited_class_is_refused_with_its_refill_instant() {
1198        let base = Instant::now();
1199        // 1000 bytes/s, 1000-byte burst: the first 1000-byte unit fits, the
1200        // second needs a full second.
1201        let s = shared_bucket(Some(1_000), 1_000, Discipline::Fifo, &[("v", 0, 1)], base);
1202        assert!(granted(&s.acquire(Class::Rule(0), 1_000, base)));
1203        match s.acquire(Class::Rule(0), 1_000, base) {
1204            Acquire::Later(at) => assert_eq!(at, base + Duration::from_secs(1)),
1205            other => panic!("expected a refill instant, got {other:?}"),
1206        }
1207        assert!(granted(&s.acquire(Class::Rule(0), 1_000, base + Duration::from_secs(1))));
1208    }
1209
1210    /// **A burst too small for one unit is its own answer, claimable once**
1211    /// — not the answer a stopped class gives and not the answer a merely
1212    /// slow class gives.
1213    /// All three postures are driven in one body against the same unit size,
1214    /// because the whole claim is that they are distinguishable and a single
1215    /// posture cannot show that. Before the split, the first and the second
1216    /// were the same value and the run reported the same clamp for both, so *1
1217    /// MB/s delivering at the hold clamp* looked exactly like *this class was
1218    /// configured to stop*.
1219    #[test]
1220    fn a_burst_below_one_unit_is_separable_from_a_stopped_or_a_slow_class() {
1221        let base = Instant::now();
1222        const UNIT: u64 = 1_000;
1223
1224        // The defect: a real rate, a burst that cannot hold one object.
1225        let mis_sized = shared_bucket(Some(1_000_000), 100, Discipline::Fifo, &[("v", 0, 1)], base);
1226        match mis_sized.acquire(Class::Rule(0), UNIT, base) {
1227            Acquire::LargerThanBurst { burst_bytes, unit_bytes } => {
1228                assert_eq!((burst_bytes, unit_bytes), (100, UNIT), "both figures, as configured");
1229            }
1230            other => panic!(
1231                "a rate that was asked for and a burst that cannot cover one object \
1232                 is a misconfiguration, not a rate limit: {other:?}"
1233            ),
1234        }
1235        assert_eq!(
1236            mis_sized.claim_burst_report(Class::Rule(0)).as_deref(),
1237            Some("v"),
1238            "the report names the class whose rate is not being applied"
1239        );
1240        assert_eq!(
1241            mis_sized.claim_burst_report(Class::Rule(0)),
1242            None,
1243            "once per session per class: the refusal repeats on every unit and the \
1244             report must not"
1245        );
1246        assert_eq!(
1247            mis_sized.claim_burst_report(Class::Default),
1248            None,
1249            "the rows that name no bucket never reached a bucket to be refused by"
1250        );
1251
1252        // A class configured to stop. Same unit, same clamp downstream, and
1253        // deliberately a different answer: nothing is wrong with it.
1254        let stopped = shared_bucket(Some(0), 0, Discipline::Fifo, &[("v", 0, 1)], base);
1255        assert!(
1256            matches!(stopped.acquire(Class::Rule(0), UNIT, base), Acquire::Never),
1257            "a zero rate is a class doing what it was asked, whatever the burst is"
1258        );
1259
1260        // A class that is merely slow: the burst covers a unit, so the
1261        // bucket refuses with the instant it will hold one again.
1262        let slow = shared_bucket(Some(1_000), UNIT, Discipline::Fifo, &[("v", 0, 1)], base);
1263        assert!(granted(&slow.acquire(Class::Rule(0), UNIT, base)));
1264        match slow.acquire(Class::Rule(0), UNIT, base) {
1265            Acquire::Later(at) => assert_eq!(at, base + Duration::from_secs(1)),
1266            other => panic!("a burst that holds a unit names a refill instant: {other:?}"),
1267        }
1268    }
1269
1270    /// Strict priority's mechanism, without QUIC: on one shared non-zero
1271    /// bucket, the
1272    /// lower-priority class is refused **while the higher one has demand**
1273    /// and released the moment that demand withdraws.
1274    ///
1275    /// The two halves are one test because neither means anything alone: a
1276    /// park that is never released is a stall, and a release that never
1277    /// parked is `Fifo` wearing a different name.
1278    #[test]
1279    fn strict_priority_parks_the_low_class_until_the_high_one_withdraws() {
1280        let base = Instant::now();
1281        let s = shared_bucket(
1282            Some(1_000_000),
1283            1_000_000,
1284            Discipline::StrictPriority,
1285            &[("audio", 9, 1), ("video", 1, 1)],
1286            base,
1287        );
1288
1289        // Nobody is holding anything: the bucket alone decides, and it grants.
1290        assert!(granted(&s.acquire(Class::Rule(1), 10, base)));
1291
1292        s.declare_demand(Class::Rule(0));
1293        let gate = match s.acquire(Class::Rule(1), 10, base) {
1294            Acquire::Starved(gate) => gate,
1295            other => panic!("audio has demand and outranks video; got {other:?}"),
1296        };
1297        assert!(!gate.is_released(), "the blocking class is still holding it");
1298        // The high class is not held back by the low one's demand.
1299        s.declare_demand(Class::Rule(1));
1300        assert!(granted(&s.acquire(Class::Rule(0), 10, base)));
1301
1302        s.withdraw_demand(Class::Rule(0));
1303        assert!(
1304            gate.is_released(),
1305            "the gate a starved class parked on opens when its blocker drains"
1306        );
1307        assert!(granted(&s.acquire(Class::Rule(1), 10, base)));
1308    }
1309
1310    /// Weighted round robin's mechanism, without QUIC: two classes with
1311    /// demand on one bucket
1312    /// under `WeightedRoundRobin` are granted in their **weight ratio**,
1313    /// because the ratio is a count and not a rate.
1314    ///
1315    /// The bucket is deliberately unlimited, so nothing but the discipline
1316    /// can shape the counts, and each class asks the way a stream's release
1317    /// branch does — `while let Some(unit) = pop_next_due(..)`, i.e. until it
1318    /// is refused. Asking exactly once per turn instead would cap a class at
1319    /// one grant per turn whatever its weight, and the harness rather than
1320    /// the discipline would be setting the ratio.
1321    ///
1322    /// The ratio is asserted as a **band**, not an equality, and the reason
1323    /// is arithmetic rather than caution: a round hands out `weight` grants
1324    /// per class, so the totals are exact only when sampled on a round
1325    /// boundary and the last partial round moves them by at most `weight`.
1326    /// The band still separates 3:1 from 1:1 by a factor of three.
1327    #[test]
1328    fn weighted_round_robin_grants_in_the_weight_ratio() {
1329        let base = Instant::now();
1330        let s = shared_bucket(
1331            None,
1332            0,
1333            Discipline::WeightedRoundRobin,
1334            &[("a", 0, 3), ("b", 0, 1)],
1335            base,
1336        );
1337        s.declare_demand(Class::Rule(0));
1338        s.declare_demand(Class::Rule(1));
1339
1340        let mut counts = [0u32; 2];
1341        for _ in 0..20 {
1342            for (index, count) in counts.iter_mut().enumerate() {
1343                while granted(&s.acquire(Class::Rule(index), 1, base)) {
1344                    *count += 1;
1345                }
1346            }
1347        }
1348        assert!(counts[1] > 0, "the low-weight class must still be scheduled, not starved");
1349        assert!(
1350            counts[0] >= 2 * counts[1],
1351            "weights 3:1 must give the heavy class at least twice the count: {counts:?}"
1352        );
1353        assert!(counts[0] <= 4 * counts[1], "weights 3:1 are a share, not a monopoly: {counts:?}");
1354    }
1355
1356    /// A class with no rival holding demand takes the whole bucket under
1357    /// `WeightedRoundRobin`, rather than stalling once its own credits run
1358    /// out. A round is over when everyone *who wants it* has had their share.
1359    #[test]
1360    fn weighted_round_robin_does_not_wait_for_a_class_with_nothing_to_send() {
1361        let base = Instant::now();
1362        let s = shared_bucket(
1363            None,
1364            0,
1365            Discipline::WeightedRoundRobin,
1366            &[("a", 0, 3), ("b", 0, 1)],
1367            base,
1368        );
1369        s.declare_demand(Class::Rule(0));
1370        for i in 0..20 {
1371            assert!(granted(&s.acquire(Class::Rule(0), 1, base)), "grant {i} was refused");
1372        }
1373    }
1374
1375    /// `Fifo` arbitrates nothing: demand from another class changes no
1376    /// answer, and the bucket is the only thing that can refuse.
1377    ///
1378    /// This is the control for the two discipline tests above — without it
1379    /// they could both be passing because `acquire` refuses whenever *any*
1380    /// other class has demand.
1381    #[test]
1382    fn fifo_never_parks_a_class() {
1383        let base = Instant::now();
1384        let s = shared_bucket(None, 0, Discipline::Fifo, &[("a", 9, 1), ("b", 0, 1)], base);
1385        s.declare_demand(Class::Rule(0));
1386        for _ in 0..10 {
1387            assert!(granted(&s.acquire(Class::Rule(1), 1_000, base)));
1388        }
1389    }
1390
1391    /// A withdrawal that outnumbers its declarations does not wrap the count
1392    /// into a permanent block.
1393    ///
1394    /// The queue withdraws from `clear`, from the teardown drain and from
1395    /// `Drop`, and those can overlap; a `u64` going through zero would make
1396    /// a class look like it had four billion streams waiting and starve
1397    /// everything under it for the rest of the session.
1398    #[test]
1399    fn withdrawing_more_than_was_declared_saturates_at_zero() {
1400        let base = Instant::now();
1401        let s =
1402            shared_bucket(None, 0, Discipline::StrictPriority, &[("hi", 9, 1), ("lo", 0, 1)], base);
1403        s.declare_demand(Class::Rule(0));
1404        s.withdraw_demand(Class::Rule(0));
1405        s.withdraw_demand(Class::Rule(0));
1406        s.withdraw_demand(Class::Rule(0));
1407        assert!(granted(&s.acquire(Class::Rule(1), 1, base)));
1408    }
1409}